/** * Canali della chat — logica condivisa tra portale cliente e inbox admin. * * `comments` è polimorfica (entity_type + entity_id) e non conosce il concetto di * "canale": lo deriviamo qui, in un solo posto, perché le due sponde devono * concordare al carattere. Se cliente e admin calcolassero il canale ognuno per * conto suo, una risposta finirebbe in un tab diverso da quello in cui è stata * scritta la domanda — che è esattamente il bug che questa riorganizzazione chiude. * * Convenzione delle chiavi: * "Generale" -> clients.id * una fase -> phases.id * task -> la chiave della fase proprietaria (tasks.phase_id) * deliverable -> la chiave della fase del task proprietario * * Task e deliverable non sono più scrivibili da nessuna UI, ma lo storico esiste: * rientra nel canale della fase invece di restare orfano, conservando il nome * dell'entità come badge sul messaggio. * * Funzioni pure: nessun accesso al DB, nessun import di server-only. */ export const GENERAL_LABEL = "Generale"; export type ChatChannel = { /** clients.id per "Generale", phases.id per le fasi. */ key: string; type: "general" | "phase"; label: string; }; type PhaseLike = { id: string; title: string; tasks?: ReadonlyArray; }; type TaskLike = { id: string; title?: string; deliverables?: ReadonlyArray<{ id: string; title?: string }>; }; export type ChannelIndex = { /** entity_id -> chiave del canale. */ channelOf: ReadonlyMap; /** * entity_id -> nome dell'entità, popolato SOLO per task e deliverable: dentro * un canale-fase serve a distinguere "su cosa" era il messaggio storico. * Vuoto per general e phase, dove l'etichetta è già il nome del tab. */ entityLabel: ReadonlyMap; }; /** L'elenco dei tab: Generale in testa, poi le fasi nell'ordine ricevuto. */ export function buildChannels( clientId: string, phases: ReadonlyArray<{ id: string; title: string }> ): ChatChannel[] { return [ { key: clientId, type: "general", label: GENERAL_LABEL }, ...phases.map((p) => ({ key: p.id, type: "phase" as const, // Una fase senza titolo romperebbe il tab: meglio un'etichetta muta che vuota. label: p.title?.trim() || "Fase senza titolo", })), ]; } /** Mappa ogni entità del progetto al canale in cui i suoi messaggi vanno letti. */ export function buildChannelIndex( clientId: string, phases: ReadonlyArray ): ChannelIndex { const channelOf = new Map(); const entityLabel = new Map(); channelOf.set(clientId, clientId); for (const phase of phases) { channelOf.set(phase.id, phase.id); for (const task of phase.tasks ?? []) { channelOf.set(task.id, phase.id); if (task.title) entityLabel.set(task.id, task.title); for (const deliverable of task.deliverables ?? []) { channelOf.set(deliverable.id, phase.id); if (deliverable.title) entityLabel.set(deliverable.id, deliverable.title); } } } return { channelOf, entityLabel }; } /** * Canale di un messaggio. Il fallback su "Generale" non è cosmetico: se una fase * viene cancellata, i suoi messaggi resterebbero senza tab e sparirebbero dalla * vista senza alcun errore. Meglio farli riemergere in Generale. */ export function channelOfComment( comment: { entity_id: string }, index: ChannelIndex, clientId: string ): string { return index.channelOf.get(comment.entity_id) ?? clientId; } /** * Forma strutturale di un messaggio, compatibile sia con la riga Drizzle * (`created_at: Date`) sia con la stessa riga passata via JSON dal poll * (`created_at: string`). Evita il cast `as unknown as Comment[]` che serviva * per far entrare i comment nel tipo legacy ClientView. */ export type ChatMessage = { id: string; entity_type: string; entity_id: string; author: string; body: string; created_at: Date | string; }; /** Tutto ciò che il pannello riceve dal server al primo render. */ export type ChatData = { /** Serve al poll: la chat è per progetto, come getProjectView. */ projectId: string; messages: ChatMessage[]; /** channel_key -> ISO dell'ultima lettura del cliente. */ reads: Record; };