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:
@@ -1,7 +1,9 @@
|
||||
import { cache } from "react";
|
||||
import { db } from "@/db";
|
||||
import { clients, projects, phases, tasks, deliverables, comments } from "@/db/schema";
|
||||
import { eq, inArray, asc } from "drizzle-orm";
|
||||
import type { Comment } from "@/db/schema";
|
||||
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
|
||||
|
||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -26,6 +28,13 @@ export type ThreadMessage = {
|
||||
created_at: Date;
|
||||
entityLabel: string;
|
||||
entityType: string;
|
||||
/**
|
||||
* Canale in cui il messaggio va letto: clientId per "Generale", phases.id per
|
||||
* una fase. Task e deliverable rientrano nella fase proprietaria — la regola
|
||||
* sta in chat-channels.ts, condivisa col portale, perché le due sponde devono
|
||||
* mettere lo stesso messaggio nello stesso tab.
|
||||
*/
|
||||
channelKey: string;
|
||||
};
|
||||
|
||||
export type ConversationThread = {
|
||||
@@ -35,6 +44,14 @@ export type ConversationThread = {
|
||||
token: string;
|
||||
slug: string | null;
|
||||
messages: ThreadMessage[];
|
||||
/** Generale + tutte le fasi del cliente (di tutti i suoi progetti). */
|
||||
channels: ChatChannel[];
|
||||
/**
|
||||
* Serve alla vista per capire QUALI canali avevano novità al momento
|
||||
* dell'apertura: aprire la conversazione la segna letta subito, quindi dopo il
|
||||
* refresh il dato non sarebbe più ricostruibile.
|
||||
*/
|
||||
adminLastReadAt: Date | null;
|
||||
};
|
||||
|
||||
type ClientMeta = {
|
||||
@@ -46,7 +63,7 @@ type ClientMeta = {
|
||||
admin_last_read_at: Date | null;
|
||||
};
|
||||
|
||||
type EntityInfo = { clientId: string; label: string; type: string };
|
||||
type EntityInfo = { clientId: string; label: string; type: string; channelKey: string };
|
||||
|
||||
// ── Shared aggregation ──────────────────────────────────────────────────────────
|
||||
// Builds the entity_id → owning client map by walking
|
||||
@@ -54,13 +71,18 @@ type EntityInfo = { clientId: string; label: string; type: string };
|
||||
// under its owning client. Same pattern as admin-queries.getClientFullDetail,
|
||||
// but across ALL non-archived clients at once (cross-client inbox).
|
||||
|
||||
async function buildEntityMap(): Promise<{
|
||||
// cache(): senza, questa funzione gira quattro volte per render della pagina
|
||||
// admin — getConversations, getConversationThread, il badge nel layout e
|
||||
// InboxBand — e ogni giro sono cinque scansioni su tutto il DB.
|
||||
const buildEntityMap = cache(async function buildEntityMap(): Promise<{
|
||||
clientMeta: Map<string, ClientMeta>;
|
||||
entityMap: Map<string, EntityInfo>;
|
||||
allEntityIds: string[];
|
||||
phasesByClient: Map<string, { id: string; title: string }[]>;
|
||||
}> {
|
||||
const clientMeta = new Map<string, ClientMeta>();
|
||||
const entityMap = new Map<string, EntityInfo>();
|
||||
const phasesByClient = new Map<string, { id: string; title: string }[]>();
|
||||
|
||||
const clientRows = await db
|
||||
.select()
|
||||
@@ -77,12 +99,17 @@ async function buildEntityMap(): Promise<{
|
||||
admin_last_read_at: c.admin_last_read_at,
|
||||
});
|
||||
// A client's own id is the entity_id for "general" messages.
|
||||
entityMap.set(c.id, { clientId: c.id, label: "Generale", type: "general" });
|
||||
entityMap.set(c.id, {
|
||||
clientId: c.id,
|
||||
label: "Generale",
|
||||
type: "general",
|
||||
channelKey: c.id,
|
||||
});
|
||||
}
|
||||
|
||||
const clientIds = clientRows.map((c) => c.id);
|
||||
if (clientIds.length === 0) {
|
||||
return { clientMeta, entityMap, allEntityIds: [] };
|
||||
return { clientMeta, entityMap, allEntityIds: [], phasesByClient };
|
||||
}
|
||||
|
||||
const projectRows = await db
|
||||
@@ -96,13 +123,24 @@ async function buildEntityMap(): Promise<{
|
||||
const phaseRows = await db
|
||||
.select({ id: phases.id, project_id: phases.project_id, title: phases.title })
|
||||
.from(phases)
|
||||
.where(inArray(phases.project_id, projectIds));
|
||||
.where(inArray(phases.project_id, projectIds))
|
||||
// I tab dei canali devono uscire nello stesso ordine in cui il cliente
|
||||
// vede le fasi nella sua timeline.
|
||||
.orderBy(asc(phases.project_id), asc(phases.sort_order));
|
||||
const phaseToClient = new Map<string, string>();
|
||||
for (const ph of phaseRows) {
|
||||
const clientId = projectToClient.get(ph.project_id);
|
||||
if (!clientId) continue;
|
||||
phaseToClient.set(ph.id, clientId);
|
||||
entityMap.set(ph.id, { clientId, label: `Fase: ${ph.title}`, type: "phase" });
|
||||
entityMap.set(ph.id, {
|
||||
clientId,
|
||||
label: `Fase: ${ph.title}`,
|
||||
type: "phase",
|
||||
channelKey: ph.id,
|
||||
});
|
||||
const list = phasesByClient.get(clientId) ?? [];
|
||||
list.push({ id: ph.id, title: ph.title });
|
||||
phasesByClient.set(clientId, list);
|
||||
}
|
||||
|
||||
const phaseIds = phaseRows.map((p) => p.id);
|
||||
@@ -116,7 +154,13 @@ async function buildEntityMap(): Promise<{
|
||||
const clientId = phaseToClient.get(t.phase_id);
|
||||
if (!clientId) continue;
|
||||
taskToClient.set(t.id, clientId);
|
||||
entityMap.set(t.id, { clientId, label: `Task: ${t.title}`, type: "task" });
|
||||
entityMap.set(t.id, {
|
||||
clientId,
|
||||
label: `Task: ${t.title}`,
|
||||
type: "task",
|
||||
// Un task non è un canale: i suoi messaggi si leggono nella sua fase.
|
||||
channelKey: t.phase_id,
|
||||
});
|
||||
}
|
||||
|
||||
const taskIds = taskRows.map((t) => t.id);
|
||||
@@ -132,14 +176,21 @@ async function buildEntityMap(): Promise<{
|
||||
clientId,
|
||||
label: `Deliverable: ${d.title}`,
|
||||
type: "deliverable",
|
||||
// Come per i task: il canale è la fase del task proprietario.
|
||||
channelKey: entityMap.get(d.task_id)?.channelKey ?? clientId,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return { clientMeta, entityMap, allEntityIds: [...entityMap.keys()] };
|
||||
}
|
||||
return {
|
||||
clientMeta,
|
||||
entityMap,
|
||||
allEntityIds: [...entityMap.keys()],
|
||||
phasesByClient,
|
||||
};
|
||||
});
|
||||
|
||||
// Groups comments (already sorted asc) by owning client id.
|
||||
function groupCommentsByClient(
|
||||
@@ -208,7 +259,7 @@ export async function getConversations(): Promise<ConversationSummary[]> {
|
||||
export async function getConversationThread(
|
||||
clientId: string
|
||||
): Promise<ConversationThread | null> {
|
||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
||||
const { clientMeta, entityMap, allEntityIds, phasesByClient } = await buildEntityMap();
|
||||
const meta = clientMeta.get(clientId);
|
||||
if (!meta) return null;
|
||||
|
||||
@@ -227,6 +278,9 @@ export async function getConversationThread(
|
||||
created_at: c.created_at,
|
||||
entityLabel: info?.label ?? "Generale",
|
||||
entityType: info?.type ?? "general",
|
||||
// Entità sparita (fase cancellata): il messaggio riemerge in Generale
|
||||
// invece di restare senza tab e sparire dalla vista senza errore.
|
||||
channelKey: info?.channelKey ?? clientId,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -237,6 +291,8 @@ export async function getConversationThread(
|
||||
token: meta.token,
|
||||
slug: meta.slug,
|
||||
messages,
|
||||
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
||||
adminLastReadAt: meta.admin_last_read_at,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user