94f4a54248
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>
142 lines
4.7 KiB
TypeScript
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;
|
|
};
|