Files
clienthub/src/lib/chat-channels.ts
T
simone 94f4a54248 feat(chat): modifica dei messaggi e firma di chi risponde
Due mancanze emerse provando la chat a canali in produzione.

**Modifica dei messaggi** (migration 0022, additiva, già applicata a
prod). Modello Slack/Discord: si corregge un proprio messaggio senza
limite di tempo, il testo precedente non si conserva, accanto all'ora
compare «modificato». Scelta deliberata, annotata in STATUS.md.

Il punto delicato non è la scrittura ma la propagazione: il poll chiede
`created_at > since` e una modifica non cambia `created_at`, quindi
l'altra parte vedrebbe il testo vecchio fino a un reload. Il filtro ora
guarda anche `edited_at`, e il watermark del client è il massimo fra i
due su tutti i messaggi — senza, il server rispedirebbe lo stesso
messaggio a ogni giro per sempre. Il merge per id già esistente fa il
resto, quindi niente duplicati.

Il non-letto resta ancorato a `created_at` di proposito: correggere un
refuso non deve riaccendere il pallino di un canale già letto.

Si modifica solo ciò di cui si è autori — il controllo è su `author`,
non solo sulla proprietà dell'entità, e lo rifà il server.

**Firma in chat.** Il nome era la stringa "iamcavalli" cablata nel
pannello: il cliente leggeva il marchio dove si aspetta una persona. Ora
arriva da `settings` (nessuna migration) con foto via URL esterno, che
rispetta il vincolo LOCKED #5 — l'upload su volume non esiste, la
deroga per l'audit è scritta in CLAUDE.md ma non è mai stata costruita.
Avatar rotto o assente ricade sul monogramma.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-21 22:42:48 +02:00

142 lines
4.7 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;
/** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
edited_at?: Date | string | null;
};
/** 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>;
/**
* Come si firma chi risponde al cliente. Prima era la stringa "iamcavalli"
* cablata nel pannello: il cliente leggeva il marchio dove si aspetta una
* persona. Arriva da `settings`, con default in getAdminIdentity().
*/
adminName: string;
/** URL esterno, o null: in quel caso resta il monogramma del nome. */
adminAvatarUrl: string | null;
};