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>
This commit is contained in:
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* 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>;
|
||||
};
|
||||
Reference in New Issue
Block a user