teqo-backup/src/lib/campaignColumnVisibility.ts
Francisco Solla cb61cf1152 feat(C211): Central de Conteúdos interna — envio em lote, link, catalogação e publicação
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
2026-09-23 02:25:15 -03:00

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))
}