teqo-backup/src/lib/contentPiece.ts
Francisco Solla ca843fb4bb
Some checks are pending
Deploy / preflight (push) Waiting to run
Deploy / verify (push) Blocked by required conditions
Deploy / deploy staging (teqo-staging) (push) Blocked by required conditions
Deploy / requeue when main advanced (push) Blocked by required conditions
Deploy / deploy production (teqo-1313) (push) Blocked by required conditions
fix(C230): fallback da catalogação dá título semântico, não placeholder
Quando a sugestão por IA falhava (timeout de 8s) ou a peça não tinha
texto, o fallback preservava o título provisório `Instagram · <código>`
para sempre. Agora isContentPieceLinkTitle (dono em src/lib) identifica o
placeholder e o fallback gera título determinístico (tipo + cidade +
temas); o caminho sem texto faz o mesmo em vez de deixar o placeholder,
a sugestão da IA que ecoa o placeholder é descartada, e título editado
pela assessoria continua intocado.
2026-10-01 02:29:19 -03:00

668 lines
25 KiB
TypeScript

/**
* C211 — pure rules of a campaign content piece ("peça"): the type/status/step
* vocabulary, the upload limits, the honest failure copy and the link
* normalization. No I/O and no `server-only`: the client dialogs, the JSON
* routes, the job, the loaders and the unit tests share this module.
*
* The piece is the internal catalogue the public Central (S27) consumes: this
* module never implements a public surface — it owns the vocabulary and the
* derivations both sides agree on.
*/
import {
CAMPAIGN_COMMUNICATION_CONTEUDOS,
campaignContentPieceFileHref,
campaignContentPieceRetryHref,
} from '@/lib/campaignPaths'
import { formatRecordingFileSize } from '@/lib/recording'
import { formatSpeechClock, formatSpeechDate } from '@/lib/speechClock'
import { SPEECH_TOPICS, type SpeechTopic } from '@/lib/speechFacets'
import { normalizeForSearch } from '@/lib/speechSearch'
/** Owner of the private upload collection slug (collection, config and routes). */
export const CONTENT_MEDIA_SLUG = 'contentMedia' as const
export const CONTENT_PIECE_TYPES = ['video', 'foto', 'texto', 'audio', 'card'] as const
export type ContentPieceType = (typeof CONTENT_PIECE_TYPES)[number]
export const contentPieceTypeLabels: Record<ContentPieceType, string> = {
video: 'Vídeo',
foto: 'Foto',
texto: 'Texto',
audio: 'Áudio',
card: 'Card',
}
export const isContentPieceType = (value: unknown): value is ContentPieceType =>
CONTENT_PIECE_TYPES.includes(value as ContentPieceType)
export const CONTENT_PIECE_STATUSES = ['rascunho', 'publicado'] as const
export type ContentPieceStatus = (typeof CONTENT_PIECE_STATUSES)[number]
export const contentPieceStatusLabels: Record<ContentPieceStatus, string> = {
rascunho: 'Rascunho',
publicado: 'Publicado',
}
export const isContentPieceStatus = (value: unknown): value is ContentPieceStatus =>
CONTENT_PIECE_STATUSES.includes(value as ContentPieceStatus)
export const CONTENT_PIECE_PROCESSING_STATUSES = ['processando', 'pronto', 'falhou'] as const
export type ContentPieceProcessingStatus = (typeof CONTENT_PIECE_PROCESSING_STATUSES)[number]
export const contentPieceProcessingStatusLabels: Record<ContentPieceProcessingStatus, string> = {
processando: 'Processando',
pronto: 'Pronto',
falhou: 'Falhou',
}
export const isContentPieceProcessingStatus = (
value: unknown,
): value is ContentPieceProcessingStatus =>
CONTENT_PIECE_PROCESSING_STATUSES.includes(value as ContentPieceProcessingStatus)
export const CONTENT_PIECE_STEPS = [
'extraindo',
'transcrevendo',
'catalogando',
'salvando',
] as const
export type ContentPieceStep = (typeof CONTENT_PIECE_STEPS)[number]
export const contentPieceStepLabels: Record<ContentPieceStep, string> = {
extraindo: 'Preparando a mídia da peça',
transcrevendo: 'Transcrevendo a peça',
catalogando: 'Catalogando a peça',
salvando: 'Guardando a catalogação',
}
export const isContentPieceStep = (value: unknown): value is ContentPieceStep =>
CONTENT_PIECE_STEPS.includes(value as ContentPieceStep)
export const CONTENT_PIECE_ORIGINS = ['arquivo', 'instagram', 'youtube'] as const
export type ContentPieceOrigin = (typeof CONTENT_PIECE_ORIGINS)[number]
export const contentPieceOriginLabels: Record<ContentPieceOrigin, string> = {
arquivo: 'Arquivo enviado',
instagram: 'Instagram',
youtube: 'YouTube',
}
export const isContentPieceOrigin = (value: unknown): value is ContentPieceOrigin =>
CONTENT_PIECE_ORIGINS.includes(value as ContentPieceOrigin)
const topicValues = new Set<string>(SPEECH_TOPICS.map((topic) => topic.value))
export const isContentPieceTopic = (value: unknown): value is SpeechTopic =>
topicValues.has(value as SpeechTopic)
export const contentPieceTopicLabel = (value: SpeechTopic): string =>
SPEECH_TOPICS.find((topic) => topic.value === value)?.label ?? value
/**
* The catalog fields the pipeline may fill and the assessoria may curate. The
* job never overwrites a field listed in `curatedFields` (D6) — the vocabulary
* is persisted, so it stays one literal per field name.
*/
export const CONTENT_PIECE_CURATED_FIELDS = [
'title',
'description',
'topics',
'municipality',
'institution',
'pieceDate',
'transcript',
'type',
] as const
export type ContentPieceCuratedField = (typeof CONTENT_PIECE_CURATED_FIELDS)[number]
export const isContentPieceCuratedField = (value: unknown): value is ContentPieceCuratedField =>
CONTENT_PIECE_CURATED_FIELDS.includes(value as ContentPieceCuratedField)
/** pt-BR names of the curated fields for the admin's read-only chip list. */
export const contentPieceCuratedFieldLabels: Record<ContentPieceCuratedField, string> = {
title: 'Título',
description: 'Descrição',
topics: 'Temas',
municipality: 'Cidade',
institution: 'Instituição',
pieceDate: 'Data da peça',
transcript: 'Transcrição / texto',
type: 'Tipo',
}
/** One owner for the persisted field limits (collection, schema and dialogs). */
export const CONTENT_PIECE_TITLE_MAX_LENGTH = 200
export const CONTENT_PIECE_DESCRIPTION_MAX_LENGTH = 1200
export const CONTENT_PIECE_INSTITUTION_MAX_LENGTH = 160
/**
* S37 — "who appears in the piece": the campaign leaders linked to the
* existing `leadership` records and the curated public figures. The limits are
* the same on every path (collection, form schema and the picker).
*/
export const CONTENT_PIECE_LEADERS_MAX = 20
export const CONTENT_PIECE_PUBLIC_FIGURES_MAX = 20
export const CONTENT_PIECE_PUBLIC_FIGURE_MAX_LENGTH = 120
/** The gated name search never returns more than this many options. */
export const CONTENT_PIECE_LEADER_SEARCH_LIMIT = 20
/** Batch policy: a generous ceiling with a clear message above it (product A). */
export const CONTENT_PIECE_BATCH_MAX_FILES = 50
/** Per-file ceiling: the same guard as a recording — hours of media stream, never buffer. */
export const CONTENT_PIECE_MAX_BYTES = 4 * 1024 * 1024 * 1024
export const CONTENT_PIECE_MAX_SIZE_LABEL = '4 GB'
/** A text piece is a document, not a media dump; the extraction reads it whole. */
export const CONTENT_PIECE_TEXT_MAX_BYTES = 5 * 1024 * 1024
const CONTENT_PIECE_TEXT_MAX_SIZE_LABEL = '5 MB'
/** The video/audio extraction timeout has no duration to scale from; 30 min is ample. */
export const CONTENT_PIECE_FFMPEG_TIMEOUT_MS = 30 * 60_000
/**
* The causes the job and the reaper store in `error` (the operator's detail)
* and the honest pt-BR copy the detail shows. A raw transport message
* (URL, stderr) stays in the admin.
*/
export const CONTENT_PIECE_FAILURE_INTERRUPTED =
'O processamento foi interrompido antes de terminar.'
const CONTENT_PIECE_FAILURE_EXTRACTING_COPY = 'Não foi possível preparar a mídia da peça.'
const CONTENT_PIECE_FAILURE_TRANSCRIBING_COPY = 'Não foi possível transcrever a peça.'
const CONTENT_PIECE_FAILURE_CATALOGING_COPY = 'Não foi possível catalogar a peça.'
const CONTENT_PIECE_FAILURE_SAVING_COPY = 'Não foi possível guardar a catalogação.'
const CONTENT_PIECE_FAILURE_UNKNOWN_COPY = 'Não foi possível processar a peça.'
/**
* Maps the stored cause of a failed piece to what the person reads: the exact
* literal when the job named it, otherwise the failure step, otherwise a
* generic-but-honest line. Null when the row stored no cause at all.
*/
export const contentPieceFailureMessage = ({
error,
step,
}: {
error?: string | null
step?: ContentPieceStep | null
}): string | null => {
const stored = error?.trim()
if (!stored) return null
if (stored === CONTENT_PIECE_FAILURE_INTERRUPTED) return CONTENT_PIECE_FAILURE_INTERRUPTED
if (step === 'extraindo') return CONTENT_PIECE_FAILURE_EXTRACTING_COPY
if (step === 'transcrevendo') return CONTENT_PIECE_FAILURE_TRANSCRIBING_COPY
if (step === 'catalogando') return CONTENT_PIECE_FAILURE_CATALOGING_COPY
if (step === 'salvando') return CONTENT_PIECE_FAILURE_SAVING_COPY
return CONTENT_PIECE_FAILURE_UNKNOWN_COPY
}
/** The retry button only applies to a failed piece. */
export const canRetryContentPiece = (processingStatus: ContentPieceProcessingStatus): boolean =>
processingStatus === 'falhou'
/**
* What the pipeline runs for each type: video and audio are transcribed, text
* has its content extracted. Photo and card are ready on upload (the aceite
* does not ask for transcription; the cataloguing still fills the metadata).
*/
export const needsContentPieceProcessing = (type: ContentPieceType): boolean =>
type === 'video' || type === 'audio' || type === 'texto'
/**
* The S27 contract, as a pure predicate: a piece is public only once published
* AND with something to show (an archived file or the platform link).
*/
export const contentPieceIsPublic = (piece: {
status: ContentPieceStatus
hasFile: boolean
sourceUrl: string | null
}): boolean => piece.status === 'publicado' && (piece.hasFile || Boolean(piece.sourceUrl))
const VIDEO_EXTENSIONS = ['mp4', 'mov', 'mkv', 'webm', 'm4v', 'avi']
const AUDIO_EXTENSIONS = ['mp3', 'wav', 'm4a', 'aac', 'ogg', 'oga', 'opus', 'flac']
const TEXT_EXTENSIONS = ['txt', 'md', 'markdown', 'csv', 'text']
const IMAGE_EXTENSIONS = ['jpg', 'jpeg', 'png', 'webp', 'heic', 'heif', 'gif', 'avif']
const extensionOf = (filename: string | null | undefined): string =>
(filename ?? '')
.toLowerCase()
.split('.')
.pop()
?.replace(/[^a-z0-9]/g, '') ?? ''
/**
* The piece type the upload derives from the file. MIME is the primary signal
* (`video/*` cannot be a photo), the extension the fallback for the formats the
* OS often reports without a type (the C199 `.mkv` lesson). Images land as
* `foto` — whether the image is a card is an editorial call the assessoria
* makes on the ficha, and the type is editable; a card is not a distinct file.
* Null means the upload refuses the file.
*/
export const contentPieceTypeFromMime = (
mimeType: string | null | undefined,
filename?: string | null,
): ContentPieceType | null => {
const mime = (mimeType ?? '').toLowerCase()
if (mime.startsWith('video/')) return 'video'
if (mime.startsWith('audio/')) return 'audio'
if (mime.startsWith('text/')) return 'texto'
if (mime.startsWith('image/')) return 'foto'
const extension = extensionOf(filename)
if (VIDEO_EXTENSIONS.includes(extension)) return 'video'
if (AUDIO_EXTENSIONS.includes(extension)) return 'audio'
if (TEXT_EXTENSIONS.includes(extension)) return 'texto'
if (IMAGE_EXTENSIONS.includes(extension)) return 'foto'
return null
}
/** The file types the picker offers — the four families the catalogue reads. */
export const CONTENT_PIECE_ACCEPT =
'video/*,audio/*,image/*,text/*,.mkv,.mov,.mp4,.webm,.mp3,.wav,.m4a,.ogg,.opus,.txt,.md,.markdown'
/** Basename without path parts or unsafe characters, keeping the extension. */
export const sanitizeContentPieceFilename = (filename: string): string => {
const base = filename.split(/[\\/]/).pop() ?? ''
const safe = base.replace(/[^\w.-]+/g, '_').replace(/^[._]+/, '')
return safe || 'peca'
}
export const contentPieceTooLargeMessage = (bytes: number): string =>
`Este arquivo tem ${formatRecordingFileSize(bytes)}. Escolha uma peça de até ${CONTENT_PIECE_MAX_SIZE_LABEL}.`
export const CONTENT_PIECE_BATCH_LIMIT_MESSAGE = `Envie até ${CONTENT_PIECE_BATCH_MAX_FILES} arquivos por lote.`
export const CONTENT_PIECE_TEXT_TOO_LARGE_MESSAGE = `O texto excede o limite de ${CONTENT_PIECE_TEXT_MAX_SIZE_LABEL}.`
/** Dialog copy of the batch help (approved design, scene 2). */
export const CONTENT_PIECE_BATCH_HELP = 'Cada arquivo vira um rascunho independente.'
/**
* The piece title starts from the filename (the assessoria renames it; the
* automatic cataloguing replaces it while the field is not curated).
*/
export const contentPieceTitleFromFilename = (filename: string): string => {
const base = (filename.split(/[\\/]/).pop() ?? '').replace(/\.[^.]+$/, '')
const cleaned = base.replace(/[_-]+/g, ' ').replace(/\s+/g, ' ').trim()
return cleaned || 'Nova peça'
}
/**
* The slug base of a piece: accent/case-insensitive, alphanumeric groups joined
* by `-`. Empty when the title has no usable character.
*/
export const contentPieceSlugBase = (title: string): string =>
title
.normalize('NFD')
.replace(/[\u0300-\u036f]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '')
.slice(0, 80)
/** The slug candidates in order: base, then `-2`, `-3`… (uniqueness lives in the DB). */
export const contentPieceSlugCandidates = (title: string, maximum = 50): string[] => {
const base = contentPieceSlugBase(title) || 'peca'
const candidates = [base]
for (let suffix = 2; candidates.length < maximum; suffix += 1) {
candidates.push(`${base}-${suffix}`)
}
return candidates
}
type ContentPieceSearchInput = {
title?: string | null
description?: string | null
transcript?: string | null
institution?: string | null
topics?: readonly string[] | null
cityLabel?: string | null
/** S37 — the display names of who appears in the piece (leader snapshot + figures). */
leaderNames?: readonly string[] | null
publicFigures?: readonly string[] | null
}
/**
* The normalized haystack the public/busy search matches: title, description,
* the transcript/text, the topic labels, the city/institution and — since S37 —
* the names of who appears in the piece, so searching a name finds the piece.
*/
export const contentPieceSearchText = (input: ContentPieceSearchInput): string =>
normalizeForSearch(
[
input.title ?? '',
input.description ?? '',
input.transcript ?? '',
input.institution ?? '',
...(input.topics ?? []).filter(isContentPieceTopic).map(contentPieceTopicLabel),
input.cityLabel ?? '',
...(input.leaderNames ?? []),
...(input.publicFigures ?? []),
]
.map((value) => value.trim())
.filter(Boolean)
.join(' '),
)
/**
* C220 — why a piece added by link stayed a peça-link. Closed vocabulary in
* product language (the UI only maps the label), persisted by the pipeline on
* a `pronto` row: a peça-link is a legitimate outcome, never a failure. The
* labels are the gate's verbatim literals.
*/
export const CONTENT_PIECE_LINK_FAILURE_REASONS = [
'nao-encontrado',
'carrossel',
'indisponivel',
'sem-credencial',
] as const
export type ContentPieceLinkFailureReason = (typeof CONTENT_PIECE_LINK_FAILURE_REASONS)[number]
export const contentPieceLinkFailureReasonLabels: Record<ContentPieceLinkFailureReason, string> = {
'nao-encontrado': 'Link não encontrado entre as mídias recentes do perfil',
carrossel: 'Carrossel: sem arquivo único para baixar',
indisponivel: 'Instagram indisponível no momento',
'sem-credencial': 'Sem credencial do Instagram configurada',
}
export const isContentPieceLinkFailureReason = (
value: unknown,
): value is ContentPieceLinkFailureReason =>
CONTENT_PIECE_LINK_FAILURE_REASONS.includes(value as ContentPieceLinkFailureReason)
/** The honest name of a piece that circulates only by its platform link. */
export const CONTENT_PIECE_LINK_LABEL = 'Peça-link'
type ContentPieceInstagramLink = {
origin: 'instagram'
canonicalUrl: string
shortcode: string
}
type ContentPieceYoutubeLink = {
origin: 'youtube'
canonicalUrl: string
videoId: string
}
export type ContentPieceLink = ContentPieceInstagramLink | ContentPieceYoutubeLink
/**
* The canonical path kinds of an Instagram post. The kind is presentation and
* the shortcode is the identity, so the identity probe spells all of them.
*/
const INSTAGRAM_CANONICAL_KINDS = ['p', 'reel', 'tv']
/** The kinds the paste accepts — `reels` is the same presentation as `reel`. */
const INSTAGRAM_KINDS: readonly string[] = [...INSTAGRAM_CANONICAL_KINDS, 'reels']
/**
* Normalizes an Instagram or YouTube link into its canonical form, dropping
* tracking parameters and hash. The shortcode/video id is the identity, so two
* pastes of the same post collapse into one `sourceUrl` (unique in the DB) —
* including the profile-prefixed spelling the browser copies from the grid
* (`/<profile>/reel/<shortcode>/`). Returns null for any other host — the
* piece only accepts the two platforms.
*/
export const parseContentPieceLink = (raw: string | null | undefined): ContentPieceLink | null => {
const trimmed = (raw ?? '').trim()
if (!trimmed) return null
let url: URL
try {
url = new URL(trimmed)
} catch {
return null
}
if (url.protocol !== 'http:' && url.protocol !== 'https:') return null
const host = url.hostname.toLowerCase().replace(/^www\.|^m\./, '')
const segments = url.pathname.split('/').filter(Boolean)
if (host === 'instagram.com') {
// Two accepted spellings: the canonical `/<kind>/<shortcode>/` and the one
// the browser copies from the profile, `/<profile>/<kind>/<shortcode>/`.
const [first, second, third] = segments
const prefixed = segments.length === 3
if (prefixed && (first ?? '').toLowerCase() === 'stories') return null
const kind = prefixed ? second : first
const shortcode = prefixed ? third : second
if (!kind || !shortcode || !INSTAGRAM_KINDS.includes(kind.toLowerCase())) return null
const safeShortcode = shortcode.replace(/[^A-Za-z0-9_-]/g, '')
if (!safeShortcode) return null
const canonicalKind = kind.toLowerCase() === 'reels' ? 'reel' : kind.toLowerCase()
return {
origin: 'instagram',
shortcode: safeShortcode,
canonicalUrl: `https://www.instagram.com/${canonicalKind}/${safeShortcode}/`,
}
}
if (host === 'youtube.com' || host === 'youtu.be') {
const videoId =
host === 'youtu.be'
? (segments[0] ?? '')
: segments[0] === 'watch'
? (url.searchParams.get('v') ?? '')
: ['shorts', 'live', 'embed'].includes(segments[0] ?? '')
? (segments[1] ?? '')
: ''
const safeVideoId = videoId.replace(/[^A-Za-z0-9_-]/g, '')
if (!safeVideoId) return null
return {
origin: 'youtube',
videoId: safeVideoId,
canonicalUrl: `https://www.youtube.com/watch?v=${safeVideoId}`,
}
}
return null
}
/** The platform of a link, or null when it is not an accepted one. */
export const contentPieceOriginFromLink = (
raw: string | null | undefined,
): ContentPieceOrigin | null => parseContentPieceLink(raw)?.origin ?? null
/** A short, identifyable provisional title while the cataloguing has not run. */
export const contentPieceLinkTitle = (link: ContentPieceLink): string =>
link.origin === 'instagram' ? `Instagram · ${link.shortcode}` : `YouTube · ${link.videoId}`
/**
* Whether a title is still the generated link placeholder (`contentPieceLinkTitle`).
* The cataloguing refuses to preserve it: a placeholder is never a title, so the
* deterministic fallback (or the assessoria) must replace it.
*/
export const isContentPieceLinkTitle = (title: string | null | undefined): boolean =>
/^(Instagram|YouTube) · \S+$/.test(title?.trim() ?? '')
/**
* C230 — every canonical spelling of one post's `sourceUrl`. The Instagram
* kind in the path (`p`, `reel`, `tv`) is presentation: the same post can be
* pasted or served under another kind, so the identity is the shortcode and a
* catalogue probe matches the piece through all its spellings in one `in`
* query over the unique `sourceUrl` index. YouTube has a single canonical URL
* — the video id is already the whole identity.
*/
export const contentPiecePostIdentityUrls = (link: ContentPieceLink): string[] =>
link.origin === 'instagram'
? INSTAGRAM_CANONICAL_KINDS.map(
(kind) => `https://www.instagram.com/${kind}/${link.shortcode}/`,
)
: [link.canonicalUrl]
/** One media item of the official profile feed, as the C230 import reads it. */
export type ContentPieceProfilePost = {
permalink: string
mediaType: string
mediaUrl?: string | null
}
/**
* The link-only reasons the profile metadata alone can tell: a carousel never
* offers a single file, and the API omits `media_url` on protected media. The
* other reasons only the extraction job can find, so the listing never claims
* them.
*/
export type ContentPieceProfileLinkOnlyReason = Extract<
ContentPieceLinkFailureReason,
'carrossel' | 'indisponivel'
>
export type ContentPieceProfileCandidate = {
/** The canonical link the piece will carry — what the import then creates. */
url: string
/**
* Why the piece will circulate only by its link, told by the feed metadata
* before the job runs. Null means the job will try the official file.
*/
linkOnlyReason: ContentPieceProfileLinkOnlyReason | null
}
/**
* C230 — classifies one feed media into an import candidate. The official
* metadata already answers whether a single file is offered (a carousel never
* has one; protected audio omits `media_url`), so the receipt can name the
* peça-link outcome honestly. A permalink the catalogue cannot parse is not a
* candidate at all — the importer never invents an identity.
*/
export const contentPieceProfileCandidateFromPost = (
post: ContentPieceProfilePost,
): ContentPieceProfileCandidate | null => {
const link = parseContentPieceLink(post.permalink)
if (!link) return null
return {
url: link.canonicalUrl,
linkOnlyReason:
post.mediaType === 'CAROUSEL_ALBUM' ? 'carrossel' : post.mediaUrl ? null : 'indisponivel',
}
}
export type ContentPieceViewModel = {
id: number
title: string
type: ContentPieceType
status: ContentPieceStatus
processingStatus: ContentPieceProcessingStatus
step: ContentPieceStep | null
origin: ContentPieceOrigin
typeLabel: string
originLabel: string
statusLabel: string
processingLabel: string
topics: SpeechTopic[]
cityLabel: string | null
regionLabel: string | null
durationLabel: string | null
pieceDateLabel: string | null
publishedAtLabel: string | null
failureMessage: string | null
linkFailureReason: ContentPieceLinkFailureReason | null
linkFailureReasonLabel: string | null
canRetry: boolean
isPublished: boolean
hasFile: boolean
detailHref: string
fileHref: string | null
retryHref: string
}
type ContentPieceViewRecord = {
id: number
title?: string | null
/** The public slug (C222): only a once-published piece has one. */
slug?: string | null
type?: string | null
status?: string | null
processingStatus?: string | null
step?: string | null
origin?: string | null
topics?: string[] | null
cityLabel?: string | null
region?: string | null
durationSeconds?: number | null
pieceDate?: string | null
publishedAt?: string | null
error?: string | null
linkFailureReason?: string | null
media?: number | { id: number } | null
}
const normalizedType = (value: string | null | undefined): ContentPieceType =>
isContentPieceType(value) ? value : 'foto'
const normalizedStatus = (value: string | null | undefined): ContentPieceStatus =>
isContentPieceStatus(value) ? value : 'rascunho'
const normalizedProcessingStatus = (
value: string | null | undefined,
): ContentPieceProcessingStatus => (isContentPieceProcessingStatus(value) ? value : 'falhou')
const normalizedOrigin = (value: string | null | undefined): ContentPieceOrigin =>
isContentPieceOrigin(value) ? value : 'arquivo'
const dateLabelOf = (value: string | null | undefined): string | null =>
/^(\d{4})-(\d{2})-(\d{2})/.test(value ?? '') && value ? formatSpeechDate(value) : null
const durationLabelOf = (seconds: number | null | undefined): string | null =>
typeof seconds === 'number' && Number.isFinite(seconds) && seconds > 0
? formatSpeechClock(seconds)
: null
const mediaIdOf = (media: ContentPieceViewRecord['media']): number | null =>
typeof media === 'number' ? media : (media?.id ?? null)
/**
* Internal/status wire view of a piece. `error` and `createdBy` never pass
* through raw: a failure becomes the mapped `failureMessage`.
*/
export const toContentPieceViewModel = (record: ContentPieceViewRecord): ContentPieceViewModel => {
const processingStatus = normalizedProcessingStatus(record.processingStatus)
const step = isContentPieceStep(record.step) ? record.step : null
const type = normalizedType(record.type)
const status = normalizedStatus(record.status)
const origin = normalizedOrigin(record.origin)
const hasFile = mediaIdOf(record.media) !== null
const linkFailureReason = isContentPieceLinkFailureReason(record.linkFailureReason)
? record.linkFailureReason
: null
return {
id: record.id,
title: record.title?.trim() || `Peça ${record.id}`,
type,
status,
processingStatus,
step,
origin,
typeLabel: contentPieceTypeLabels[type],
originLabel: contentPieceOriginLabels[origin],
statusLabel: contentPieceStatusLabels[status],
processingLabel: contentPieceProcessingStatusLabels[processingStatus],
topics: (record.topics ?? []).filter(isContentPieceTopic),
cityLabel: record.cityLabel?.trim() || null,
regionLabel: record.region?.trim() || null,
durationLabel: durationLabelOf(record.durationSeconds),
pieceDateLabel: dateLabelOf(record.pieceDate),
publishedAtLabel: dateLabelOf(record.publishedAt),
failureMessage: contentPieceFailureMessage({ error: record.error, step }),
linkFailureReason,
linkFailureReasonLabel: linkFailureReason
? contentPieceLinkFailureReasonLabels[linkFailureReason]
: null,
canRetry: canRetryContentPiece(processingStatus),
isPublished: status === 'publicado',
hasFile,
detailHref: `${CAMPAIGN_COMMUNICATION_CONTEUDOS}/${record.id}`,
fileHref: hasFile ? campaignContentPieceFileHref(record.id) : null,
retryHref: campaignContentPieceRetryHref(record.id),
}
}