mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 15:13:07 +00:00
Envio em lote (1 request raw-body por arquivo, fan-out ≤2, até 50 arquivos) e adicionar por link (Instagram/YouTube; extração só pela Graph API da conta própria, senão peça-link) em /campanha/comunicacao/conteudos. Cada peça tem estado próprio (Processando/Pronto/Falhou + Reprocessar), transcrição/texto e catalogação automática revisável; publicar/despublicar preserva arquivo, slug e publishedAt. - collections contentPiece + contentMedia (upload privado no S3) e migration 20260923_032714_add_content_piece (slug único gerado no 1º publish, sourceUrl única, searchText com índice trigram) - pipeline reusa C199 (job after() + reaper + retry condicional, transcrição DeepInfra, ffmpeg/chunk), o classificador de facetas do acervo e o institutionCatalog; curatedFields impede o job de sobrescrever curadoria - gate único canReadCommunicationCatalog (advisor/leader negados); delete só pelo admin; sem Consent novo - UI portada do design aprovado (lista desktop + cards mobile, diálogo de lote com progresso por arquivo, diálogo de link, ficha editável, kill switch); nav/chrome/column-picker/manifest atualizados - instagramFeed expõe mediaUrl; revalidateTag ganha wrapper seguro fora de request (fixtures/scripts) - testes: 2 unit specs, 1 int spec (16 casos) e 7 e2e na vertical - follow-up de engenharia: #1267 (docs/plans/escala-dry-pos-c211.md) Closes #1254
192 lines
7.1 KiB
TypeScript
192 lines
7.1 KiB
TypeScript
/**
|
|
* B17 — which columns of a `CampaignTable` the actor chose to hide.
|
|
*
|
|
* Pure and client-safe: the picker (client) serializes, the list surfaces
|
|
* (RSC) parse and filter. Persistence is a cookie rather than `localStorage`
|
|
* because the column array never crosses the server/client boundary
|
|
* (`CampaignTableColumn.cell` is a function), so the server is the only place
|
|
* that can drop a column — and a cookie is the only per-device preference the
|
|
* server can read on the first paint, with no flash of the full table.
|
|
*/
|
|
|
|
export const CAMPAIGN_COLUMNS_COOKIE = 'campaign_columns'
|
|
/** Scoped like the campaign session cookie: the picker only exists under /campanha. */
|
|
export const CAMPAIGN_COLUMNS_COOKIE_PATH = '/campanha'
|
|
export const CAMPAIGN_COLUMNS_COOKIE_MAX_AGE = 60 * 60 * 24 * 365
|
|
|
|
/** A malformed or oversized cookie shows the full table instead of guessing. */
|
|
const MAX_COOKIE_LENGTH = 2048
|
|
|
|
const CAMPAIGN_LIST_IDS = [
|
|
'municipios',
|
|
'liderancas',
|
|
'dobradinhas',
|
|
'organizacoes',
|
|
'demandas',
|
|
'apoiadores',
|
|
'territorios',
|
|
'pessoas',
|
|
// B197 — the advisors surface is a hand-rolled client table (no
|
|
// `CampaignTable`), but it joined the picker with the same cookie key.
|
|
'assessores',
|
|
// C139 — the contacts page (staff) joins the picker with the same key
|
|
// contract; the ficha fields are the columns.
|
|
'contatos',
|
|
// C211 — the internal Central de Conteúdos list.
|
|
'conteudos',
|
|
] as const
|
|
|
|
export type CampaignListId = (typeof CAMPAIGN_LIST_IDS)[number]
|
|
|
|
export type CampaignHiddenColumns = Partial<Record<CampaignListId, string[]>>
|
|
|
|
/**
|
|
* What one list surface needs to run the picker, as ONE value. The id is what
|
|
* the cookie is keyed by and what the picker writes back, so the reader and
|
|
* the table must agree on it — as two props they could disagree between two
|
|
* perfectly valid ids, and the only symptom would be a picker that does nothing.
|
|
*/
|
|
export type CampaignColumnVisibility = {
|
|
listId: CampaignListId
|
|
hiddenColumnIds: readonly string[]
|
|
}
|
|
|
|
/** Serializable column metadata for `CampaignColumnPicker` (B17 / B137). */
|
|
export type CampaignColumnPickerColumn = {
|
|
id: string
|
|
label: string
|
|
mandatory?: boolean
|
|
}
|
|
|
|
export const toCampaignColumnPickerColumns = (
|
|
columns: readonly { id: string; label: string; mandatory?: boolean }[],
|
|
): CampaignColumnPickerColumn[] =>
|
|
columns.map(({ id, label, mandatory }) => ({ id, label, mandatory }))
|
|
|
|
/**
|
|
* `listId:colA~colB|otherList:colC`. Every character here is legal in a
|
|
* cookie value (RFC 6265 forbids comma, semicolon, whitespace, quotes and
|
|
* backslash), so nothing is percent-encoded — which matters because
|
|
* `document.cookie` and Next's `cookies()` disagree about decoding.
|
|
*/
|
|
const LIST_SEPARATOR = '|'
|
|
const LIST_ID_SEPARATOR = ':'
|
|
const COLUMN_SEPARATOR = '~'
|
|
const EXPLICIT_EMPTY_COLUMNS = '__none__'
|
|
const COLUMN_ID_PATTERN = /^[A-Za-z0-9_-]+$/
|
|
|
|
const DEFAULT_HIDDEN_COLUMN_IDS: Partial<Record<CampaignListId, readonly string[]>> = {
|
|
municipios: ['goalCoverage', 'lastSignal'],
|
|
// B175 — Cobertura is the deepest rung (P3) and only shows when the actor
|
|
// opts in from the picker, mirroring the municipios precedent.
|
|
territorios: ['cobertura'],
|
|
// B197 — the person lists open without the email column; the channel of the
|
|
// day is phone/WhatsApp. Anyone can re-enable it from the picker, and a
|
|
// device with an existing choice keeps it (stored wins over default).
|
|
liderancas: ['email'],
|
|
// C129 — the "Nome de legenda" column starts hidden: the subline under the
|
|
// name is the always-on display, and the column is the opt-in edit surface
|
|
// (same email-column precedent: display carries the day, editing stays reachable).
|
|
dobradinhas: ['email', 'ballotName'],
|
|
pessoas: ['email'],
|
|
apoiadores: ['email'],
|
|
assessores: ['email'],
|
|
// C139 — CEP is the least-read ficha field; anyone can re-enable it from
|
|
// the picker (stored wins over default).
|
|
contatos: ['postalCode'],
|
|
}
|
|
|
|
const isCampaignListId = (value: string): value is CampaignListId =>
|
|
CAMPAIGN_LIST_IDS.some((listId) => listId === value)
|
|
|
|
export const parseCampaignHiddenColumns = (raw: string | undefined): CampaignHiddenColumns => {
|
|
if (!raw || raw.length > MAX_COOKIE_LENGTH) return {}
|
|
|
|
const parsed: CampaignHiddenColumns = {}
|
|
|
|
for (const entry of raw.split(LIST_SEPARATOR)) {
|
|
const separatorIndex = entry.indexOf(LIST_ID_SEPARATOR)
|
|
if (separatorIndex <= 0) continue
|
|
|
|
const listId = entry.slice(0, separatorIndex)
|
|
if (!isCampaignListId(listId)) continue
|
|
|
|
// An id matching nothing on the table is inert, so the length cap above is
|
|
// the only bound worth having — a per-list one would also have to be
|
|
// mirrored in `serialize`, or writing 41 and reading back 40 would differ.
|
|
const encodedColumns = entry.slice(separatorIndex + 1)
|
|
if (encodedColumns === EXPLICIT_EMPTY_COLUMNS) {
|
|
parsed[listId] = []
|
|
continue
|
|
}
|
|
|
|
const columnIds = [
|
|
...new Set(
|
|
encodedColumns
|
|
.split(COLUMN_SEPARATOR)
|
|
.filter(
|
|
(columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId),
|
|
),
|
|
),
|
|
]
|
|
|
|
if (columnIds.length) parsed[listId] = columnIds
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
export const serializeCampaignHiddenColumns = (value: CampaignHiddenColumns): string =>
|
|
CAMPAIGN_LIST_IDS.flatMap((listId) => {
|
|
if (!Object.prototype.hasOwnProperty.call(value, listId)) return []
|
|
|
|
const columnIds = (value[listId] ?? []).filter(
|
|
(columnId) => columnId !== EXPLICIT_EMPTY_COLUMNS && COLUMN_ID_PATTERN.test(columnId),
|
|
)
|
|
const encodedColumns = columnIds.length
|
|
? columnIds.join(COLUMN_SEPARATOR)
|
|
: EXPLICIT_EMPTY_COLUMNS
|
|
return [`${listId}${LIST_ID_SEPARATOR}${encodedColumns}`]
|
|
}).join(LIST_SEPARATOR)
|
|
|
|
export const resolveCampaignColumnVisibility = (
|
|
listId: CampaignListId,
|
|
hiddenColumns: CampaignHiddenColumns,
|
|
): CampaignColumnVisibility => ({
|
|
listId,
|
|
hiddenColumnIds: Object.prototype.hasOwnProperty.call(hiddenColumns, listId)
|
|
? (hiddenColumns[listId] ?? [])
|
|
: (DEFAULT_HIDDEN_COLUMN_IDS[listId] ?? []),
|
|
})
|
|
|
|
/**
|
|
* Records one toggle. Hiding is stored (never the visible set), so a column
|
|
* shipped by a later item — E9's derived signals, E14's level — is visible to
|
|
* everyone who never touched the picker, which is the contract those items
|
|
* assumed.
|
|
*/
|
|
export const toggleHiddenColumn = (
|
|
hiddenColumnIds: readonly string[],
|
|
columnId: string,
|
|
visible: boolean,
|
|
): string[] => {
|
|
const without = hiddenColumnIds.filter((id) => id !== columnId)
|
|
return visible ? without : [...without, columnId]
|
|
}
|
|
|
|
type HideableColumn = { id: string; mandatory?: boolean }
|
|
|
|
/**
|
|
* A `mandatory` column is never dropped, whatever the cookie says — an actor
|
|
* who lands with a stale cookie must not end up with a table he cannot read
|
|
* (or, on `/campanha/municipios`, without the only linked name in the row).
|
|
*/
|
|
export const resolveVisibleColumns = <Column extends HideableColumn>(
|
|
columns: readonly Column[],
|
|
hiddenColumnIds: readonly string[] | undefined,
|
|
): readonly Column[] => {
|
|
if (!hiddenColumnIds?.length) return columns
|
|
|
|
const hidden = new Set(hiddenColumnIds)
|
|
return columns.filter((column) => column.mandatory || !hidden.has(column.id))
|
|
}
|