Files
clienthub/src/lib/chat-channels.ts
T
simone ac74a81a72 feat(chat): chat a canali nel portale cliente e inbox admin per canale
Il portale aveva una sola conversazione con un selettore di fase in un
dropdown: il cliente non vedeva dove c'era del non letto, e una risposta
admin poteva atterrare su un'entità diversa da quella della domanda.

Ora i messaggi si organizzano in canali — "Generale" più uno per fase —
derivati in un solo posto (src/lib/chat-channels.ts) così che le due
sponde concordino sulla stessa chiave. Task e deliverable non sono più
scrivibili ma lo storico non resta orfano: rientra nel canale della fase
proprietaria conservando il nome dell'entità come badge.

- migration 0021 (additiva, già applicata in prod): client_channel_reads
  per il letto/non-letto per canale lato cliente, più il primo indice mai
  esistito su comments (entity_id, created_at)
- GET/POST /api/client/chat: polling dei messaggi e ricevuta di lettura
- ChatPanel: tab per canale, pallino di non letto, modalità full-screen
- inbox admin: tab per canale con targeting dell'entità corretta in
  risposta e snapshot di adminLastReadAt sul thread

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 17:50:44 +02:00

132 lines
4.2 KiB
TypeScript

/**
* 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<TaskLike>;
};
type TaskLike = {
id: string;
title?: string;
deliverables?: ReadonlyArray<{ id: string; title?: string }>;
};
export type ChannelIndex = {
/** entity_id -> chiave del canale. */
channelOf: ReadonlyMap<string, string>;
/**
* 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<string, string>;
};
/** 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<PhaseLike>
): ChannelIndex {
const channelOf = new Map<string, string>();
const entityLabel = new Map<string, string>();
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<string, string>;
};