mirror of
https://github.com/fsolla/teqo.git
synced 2026-11-13 06:25:20 +00:00
Hard delete por peça (sem lixeira/lote/telemetria) em qualquer estado, com confirmação que nomeia o link público só quando publicado; access próprio da vertical + admin (advisor/leader fail-closed), cascata da mídia pós-commit e bust da tag contentPieces no afterDelete; máquina do diálogo extraída no 3º call site (C183/C199/C222).
151 lines
6.3 KiB
TypeScript
151 lines
6.3 KiB
TypeScript
/**
|
|
* C213 — the four circulation counters of a piece and the honest states they
|
|
* render in. Pure and client-safe: the internal list/detail loaders resolve the
|
|
* raw aggregate rows into these shapes and the RSC surfaces render them; the
|
|
* rules (precedence, literals, labels) live here, once.
|
|
*
|
|
* The counters are absolute, anonymous and never summed: Aberturas, Downloads,
|
|
* WhatsApp and Link measure different choices, and a piece that has been
|
|
* unpublished keeps its history (the numbers happened while it was public).
|
|
*/
|
|
import type { ContentEventAggregateRow, ContentEventType } from '@/lib/contentEvents'
|
|
import type { ContentPieceViewModel } from '@/lib/contentPiece'
|
|
|
|
export type ContentPieceCirculationCounts = {
|
|
opens: number
|
|
downloads: number
|
|
whatsapp: number
|
|
link: number
|
|
}
|
|
|
|
export type ContentPieceCirculationMetric = {
|
|
key: keyof ContentPieceCirculationCounts
|
|
eventType: ContentEventType
|
|
/** Table/card label — "Link" is the short form. */
|
|
label: string
|
|
/** Detail label — "Link copiado" names the action the counter measures. */
|
|
detailLabel: string
|
|
}
|
|
|
|
export const CONTENT_PIECE_CIRCULATION_METRICS: readonly ContentPieceCirculationMetric[] = [
|
|
{ key: 'opens', eventType: 'abertura', label: 'Aberturas', detailLabel: 'Aberturas' },
|
|
{ key: 'downloads', eventType: 'download', label: 'Downloads', detailLabel: 'Downloads' },
|
|
{
|
|
key: 'whatsapp',
|
|
eventType: 'compartilhar_whatsapp',
|
|
label: 'WhatsApp',
|
|
detailLabel: 'WhatsApp',
|
|
},
|
|
{ key: 'link', eventType: 'compartilhar_link', label: 'Link', detailLabel: 'Link copiado' },
|
|
]
|
|
|
|
export const EMPTY_CONTENT_PIECE_CIRCULATION: ContentPieceCirculationCounts = Object.freeze({
|
|
opens: 0,
|
|
downloads: 0,
|
|
whatsapp: 0,
|
|
link: 0,
|
|
})
|
|
|
|
export const isContentPieceCirculationEmpty = (counts: ContentPieceCirculationCounts): boolean =>
|
|
CONTENT_PIECE_CIRCULATION_METRICS.every((metric) => counts[metric.key] <= 0)
|
|
|
|
/**
|
|
* What one surface renders for one piece:
|
|
* - `data` — the four counters (zeros included: the piece was published);
|
|
* - `neverPublished` — no counters to show, the piece never entered the public
|
|
* Central;
|
|
* - `unavailable` — the count could not be read right now (the design's "—").
|
|
*/
|
|
export type ContentPieceCirculationView =
|
|
| { state: 'data'; counts: ContentPieceCirculationCounts }
|
|
| { state: 'neverPublished' }
|
|
| { state: 'unavailable' }
|
|
|
|
export type ContentPieceRowViewModel = ContentPieceViewModel & {
|
|
circulation: ContentPieceCirculationView
|
|
/**
|
|
* C222 — the public path of the piece (`/conteudos/<slug>`), or null when it
|
|
* never got a slug. Computed by the page-data loader from the row's `slug`;
|
|
* the delete confirmation names it only when the piece is published.
|
|
*/
|
|
publicPath: string | null
|
|
}
|
|
|
|
/**
|
|
* Precedence is the honesty rule: a failed read says "unavailable"; real
|
|
* counters win over everything (an unpublished piece keeps its history); a
|
|
* published piece with no events shows the zero literal; and only a piece that
|
|
* never had a `publishedAt` says "ainda não publicada".
|
|
*/
|
|
export const resolveContentPieceCirculation = ({
|
|
counts,
|
|
hasBeenPublished,
|
|
unavailable,
|
|
}: {
|
|
counts?: ContentPieceCirculationCounts
|
|
hasBeenPublished: boolean
|
|
unavailable: boolean
|
|
}): ContentPieceCirculationView => {
|
|
if (unavailable) return { state: 'unavailable' }
|
|
if (counts && !isContentPieceCirculationEmpty(counts)) return { state: 'data', counts }
|
|
if (hasBeenPublished) return { state: 'data', counts: counts ?? EMPTY_CONTENT_PIECE_CIRCULATION }
|
|
return { state: 'neverPublished' }
|
|
}
|
|
|
|
/** Groups the aggregate rows into per-piece counters, ignoring unknown types. */
|
|
export const contentPieceCirculationFromRows = (
|
|
rows: readonly ContentEventAggregateRow[],
|
|
): Map<number, ContentPieceCirculationCounts> => {
|
|
const byPieceId = new Map<number, ContentPieceCirculationCounts>()
|
|
|
|
for (const row of rows) {
|
|
const pieceId = Number(row.subjectId)
|
|
if (!Number.isSafeInteger(pieceId) || pieceId <= 0) continue
|
|
|
|
const metric = CONTENT_PIECE_CIRCULATION_METRICS.find(
|
|
(candidate) => candidate.eventType === row.type,
|
|
)
|
|
if (!metric) continue
|
|
|
|
const counts = byPieceId.get(pieceId) ?? { ...EMPTY_CONTENT_PIECE_CIRCULATION }
|
|
const value = Number(row.count)
|
|
counts[metric.key] = Number.isFinite(value) ? Math.max(0, Math.trunc(value)) : 0
|
|
byPieceId.set(pieceId, counts)
|
|
}
|
|
|
|
return byPieceId
|
|
}
|
|
|
|
/** True when the piece is off the air but the counters are its public history. */
|
|
export const showsContentPieceCirculationHistory = (
|
|
view: ContentPieceCirculationView,
|
|
isPublished: boolean,
|
|
): boolean => view.state === 'data' && !isPublished && !isContentPieceCirculationEmpty(view.counts)
|
|
|
|
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE = '—'
|
|
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_LABEL = 'Contagem indisponível'
|
|
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_VALUE_LABEL = 'contagem indisponível'
|
|
|
|
/** The all-zero sentence of the list/card (zero takes the singular in pt-BR). */
|
|
export const CONTENT_PIECE_CIRCULATION_ZERO_LABEL = '0 abertura · 0 download · 0 WhatsApp · 0 link'
|
|
|
|
export const CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_LABEL = 'Sem dados — ainda não publicada'
|
|
export const CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_TITLE =
|
|
'Sem dados — esta peça ainda não foi publicada.'
|
|
export const CONTENT_PIECE_CIRCULATION_NEVER_PUBLISHED_BODY =
|
|
'A contagem começa quando ela entrar na Central pública.'
|
|
|
|
export const CONTENT_PIECE_CIRCULATION_HISTORY_LABEL =
|
|
'Histórico de quando esteve publicada · peça fora do ar'
|
|
export const CONTENT_PIECE_CIRCULATION_HISTORY_TITLE = 'Histórico de quando esteve publicada.'
|
|
export const CONTENT_PIECE_CIRCULATION_HISTORY_BODY = 'A peça está fora do ar.'
|
|
|
|
export const CONTENT_PIECE_CIRCULATION_DETAIL_INTRO =
|
|
'Contagem anônima e acumulada desde a publicação.'
|
|
export const CONTENT_PIECE_CIRCULATION_READING =
|
|
'compare os sinais sem somá-los. WhatsApp e link ficam separados porque mostram escolhas diferentes.'
|
|
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_TITLE = 'Contagem indisponível agora.'
|
|
export const CONTENT_PIECE_CIRCULATION_UNAVAILABLE_BODY =
|
|
'A página pública, o download e o compartilhamento continuam funcionando.'
|
|
export const CONTENT_PIECE_CIRCULATION_PRIVACY =
|
|
'sem IP persistido, cookie de identidade ou rastreio entre peças.'
|