ac74a81a72
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>
132 lines
4.2 KiB
TypeScript
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>;
|
|
};
|