From 94f4a54248755441cfa5ec1441db7635d0ff3a6f Mon Sep 17 00:00:00 2001
From: Simone Cavalli
Date: Fri, 21 Aug 2026 22:42:48 +0200
Subject: [PATCH] feat(chat): modifica dei messaggi e firma di chi risponde
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
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
---
src/app/admin/conversazioni/actions.ts | 54 +++
src/app/admin/impostazioni/page.tsx | 105 +++++-
src/app/api/client/chat/route.ts | 19 +-
src/app/api/client/comment/route.ts | 71 ++++
src/app/client/[token]/page.tsx | 9 +
.../admin/conversazioni/ConversationsView.tsx | 159 +++++++--
src/components/client/ChatPanel.tsx | 335 +++++++++++++++---
src/db/migrations/0022_comment_edits.sql | 21 ++
src/db/schema.ts | 5 +
src/lib/chat-channels.ts | 10 +
src/lib/client-view.ts | 2 +
src/lib/conversations-queries.ts | 3 +
src/lib/settings.ts | 24 ++
13 files changed, 735 insertions(+), 82 deletions(-)
create mode 100644 src/db/migrations/0022_comment_edits.sql
diff --git a/src/app/admin/conversazioni/actions.ts b/src/app/admin/conversazioni/actions.ts
index eee0c0c..567e4e9 100644
--- a/src/app/admin/conversazioni/actions.ts
+++ b/src/app/admin/conversazioni/actions.ts
@@ -72,6 +72,60 @@ export async function replyToConversation(clientId: string, formData: FormData)
revalidatePath(`/admin/clients/${clientId}`);
}
+const editSchema = z.object({
+ comment_id: z.string().min(1),
+ body: z.string().trim().min(1, "Il messaggio non può essere vuoto").max(2000),
+});
+
+/**
+ * Modifica di un messaggio già inviato dall'admin.
+ *
+ * Nessun limite di tempo (modello Slack/Discord), ma si modifica solo ciò di cui
+ * si è autori: l'admin non riscrive i messaggi del cliente. L'admin è un utente
+ * unico e fidato, ma il controllo di appartenenza resta comunque — un commentId
+ * arbitrario nel form non deve poter scrivere nel thread di un altro cliente.
+ *
+ * A differenza di replyToConversation, NON tocca admin_last_read_at: correggere
+ * un refuso non è leggere la conversazione.
+ */
+export async function editConversationMessage(
+ clientId: string,
+ formData: FormData
+) {
+ await requireAdmin();
+
+ const parsed = editSchema.safeParse({
+ comment_id: formData.get("comment_id"),
+ body: formData.get("body"),
+ });
+ if (!clientId || !parsed.success) throw new Error("Dati mancanti");
+ const { comment_id, body } = parsed.data;
+
+ const [existing] = await db
+ .select()
+ .from(comments)
+ .where(eq(comments.id, comment_id))
+ .limit(1);
+ if (!existing || existing.author !== "admin") {
+ throw new Error("Messaggio non modificabile");
+ }
+
+ const owns = await assertClientOwnsEntity(
+ clientId,
+ existing.entity_type as "general" | "phase" | "task" | "deliverable",
+ existing.entity_id
+ );
+ if (!owns) throw new Error("Messaggio non appartenente a questo cliente");
+
+ await db
+ .update(comments)
+ .set({ body, edited_at: new Date() })
+ .where(eq(comments.id, comment_id));
+
+ revalidatePath("/admin/conversazioni");
+ revalidatePath(`/admin/clients/${clientId}`);
+}
+
/** Mark a client's conversation as read up to now (clears unread dot/badge). */
export async function markConversationRead(clientId: string) {
await requireAdmin();
diff --git a/src/app/admin/impostazioni/page.tsx b/src/app/admin/impostazioni/page.tsx
index dbaa2e4..6d520f0 100644
--- a/src/app/admin/impostazioni/page.tsx
+++ b/src/app/admin/impostazioni/page.tsx
@@ -1,4 +1,10 @@
-import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings";
+import {
+ getTargetHourlyRate,
+ getAdminIdentity,
+ updateSetting,
+ SETTINGS_KEYS,
+ ADMIN_NAME_FALLBACK,
+} from "@/lib/settings";
import { getAllPools } from "@/lib/taxonomy";
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
import { PageHeader } from "@/components/admin/PageHeader";
@@ -6,7 +12,11 @@ import { PageHeader } from "@/components/admin/PageHeader";
export const revalidate = 0;
export default async function ImpostazioniPage() {
- const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]);
+ const [targetRate, pools, admin] = await Promise.all([
+ getTargetHourlyRate(),
+ getAllPools(),
+ getAdminIdentity(),
+ ]);
async function handleSave(fd: FormData) {
"use server";
@@ -16,6 +26,19 @@ export default async function ImpostazioniPage() {
await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, val.toFixed(2));
}
+ async function handleSaveIdentity(fd: FormData) {
+ "use server";
+ const name = String(fd.get("admin_display_name") ?? "").trim();
+ const avatar = String(fd.get("admin_avatar_url") ?? "").trim();
+
+ // Un URL che non è http(s) non verrebbe mai caricato dal browser: meglio non
+ // scriverlo affatto che salvarlo e lasciare l'avatar rotto senza spiegazione.
+ const validAvatar = /^https?:\/\//i.test(avatar) ? avatar : "";
+
+ await updateSetting(SETTINGS_KEYS.ADMIN_DISPLAY_NAME, name);
+ await updateSetting(SETTINGS_KEYS.ADMIN_AVATAR_URL, validAvatar);
+ }
+
return (
+
+
+
+ Firma nella chat
+
+
+ Come ti vede il cliente quando rispondi nel portale. Senza nome resta
+ «{ADMIN_NAME_FALLBACK}», che è il marchio e non una persona. Senza foto
+ resta il monogramma delle iniziali.
+
+
+
+
+
+
);
diff --git a/src/app/api/client/chat/route.ts b/src/app/api/client/chat/route.ts
index b763329..c83cf80 100644
--- a/src/app/api/client/chat/route.ts
+++ b/src/app/api/client/chat/route.ts
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
-import { and, eq, gt, inArray, asc } from "drizzle-orm";
+import { and, eq, gt, inArray, asc, or } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { comments, client_channel_reads } from "@/db/schema";
@@ -61,11 +61,26 @@ export async function GET(request: NextRequest) {
const validSince =
sinceDate && !Number.isNaN(sinceDate.getTime()) ? sinceDate : null;
+ // Un messaggio è "nuovo per il pannello" se è stato scritto DOPPURE modificato
+ // dopo `since`: una modifica non tocca `created_at`, quindi filtrando solo su
+ // quello il testo corretto non arriverebbe mai all'altra parte. Con `edited_at`
+ // a NULL il confronto è NULL — i messaggi mai modificati non vengono ripescati,
+ // quindi il caso normale non paga nulla.
const scope = inArray(comments.entity_id, entityIds);
const rows = await db
.select()
.from(comments)
- .where(validSince ? and(scope, gt(comments.created_at, validSince)) : scope)
+ .where(
+ validSince
+ ? and(
+ scope,
+ or(
+ gt(comments.created_at, validSince),
+ gt(comments.edited_at, validSince)
+ )
+ )
+ : scope
+ )
.orderBy(asc(comments.created_at));
const readRows = await db
diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts
index 8ed19c1..50d21d8 100644
--- a/src/app/api/client/comment/route.ts
+++ b/src/app/api/client/comment/route.ts
@@ -1,4 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
+import { eq } from "drizzle-orm";
import { z } from "zod";
import { db } from "@/db";
import { comments } from "@/db/schema";
@@ -12,6 +13,12 @@ const commentSchema = z.object({
body: z.string().min(1, "Il commento non può essere vuoto").max(2000),
});
+const editSchema = z.object({
+ token: z.string().min(1),
+ comment_id: z.string().min(1),
+ body: z.string().trim().min(1, "Il commento non può essere vuoto").max(2000),
+});
+
export async function POST(request: NextRequest) {
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
if (!rateLimit(`comment:${ip}`, 10, 60_000)) {
@@ -59,3 +66,67 @@ export async function POST(request: NextRequest) {
return NextResponse.json({ error: "Errore interno" }, { status: 500 });
}
}
+
+/**
+ * Modifica di un messaggio già inviato dal cliente.
+ *
+ * Nessun limite di tempo (modello Slack/Discord), ma un limite netto su CHI:
+ * si modifica solo un messaggio di cui si è autori. Il controllo su `author`
+ * non è ridondante rispetto a quello sull'entità — senza, il cliente potrebbe
+ * riscrivere le risposte dell'admin nella propria chat, che è peggio del non
+ * poter modificare affatto.
+ */
+export async function PATCH(request: NextRequest) {
+ const ip = request.headers.get("x-forwarded-for") ?? "unknown";
+ if (!rateLimit(`comment-edit:${ip}`, 10, 60_000)) {
+ return NextResponse.json({ error: "Troppe richieste" }, { status: 429 });
+ }
+
+ try {
+ const parsed = editSchema.safeParse(await request.json());
+ if (!parsed.success) {
+ return NextResponse.json(
+ { error: parsed.error.issues[0].message },
+ { status: 400 }
+ );
+ }
+ const { token, comment_id, body: newBody } = parsed.data;
+
+ const clientId = await resolveClientByToken(token);
+ if (!clientId) {
+ return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
+ }
+
+ const [existing] = await db
+ .select()
+ .from(comments)
+ .where(eq(comments.id, comment_id))
+ .limit(1);
+
+ // Stesso 403 per "non esiste" e "non è tuo": distinguerli direbbe a chi prova
+ // id a caso quali esistono.
+ if (!existing || existing.author !== "client") {
+ return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
+ }
+
+ const owns = await assertClientOwnsEntity(
+ clientId,
+ existing.entity_type as "general" | "phase" | "task" | "deliverable",
+ existing.entity_id
+ );
+ if (!owns) {
+ return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 });
+ }
+
+ const [updated] = await db
+ .update(comments)
+ .set({ body: newBody, edited_at: new Date() })
+ .where(eq(comments.id, comment_id))
+ .returning();
+
+ return NextResponse.json({ success: true, comment: updated });
+ } catch (err) {
+ console.error("/api/client/comment PATCH error:", err);
+ return NextResponse.json({ error: "Errore interno" }, { status: 500 });
+ }
+}
diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx
index 2805a8b..f0892b7 100644
--- a/src/app/client/[token]/page.tsx
+++ b/src/app/client/[token]/page.tsx
@@ -13,6 +13,7 @@ import { OtpGate } from "@/components/client/OtpGate";
import { PreviewBanner } from "@/components/client/PreviewBanner";
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { normalizeTaskStatus } from "@/lib/task-status";
+import { getAdminIdentity } from "@/lib/settings";
export const revalidate = 0;
@@ -136,6 +137,10 @@ export default async function ClientPage({
);
}
+ // Come si firma chi risponde in chat. Una lettura sola per pagina, condivisa
+ // da tutti i progetti: è la stessa persona in ogni tab.
+ const admin = await getAdminIdentity();
+
if (projects.length === 1) {
// D-09: single project → direct view without selector
const view = await getProjectView(projects[0].id);
@@ -150,6 +155,8 @@ export default async function ClientPage({
projectId: view.project.id,
messages: view.comments,
reads: view.channel_reads,
+ adminName: admin.name,
+ adminAvatarUrl: admin.avatarUrl,
}}
preview={preview}
/>
@@ -200,6 +207,8 @@ export default async function ClientPage({
projectId: view.project.id,
messages: view.comments,
reads: view.channel_reads,
+ adminName: admin.name,
+ adminAvatarUrl: admin.avatarUrl,
}}
embedded
preview={preview}
diff --git a/src/components/admin/conversazioni/ConversationsView.tsx b/src/components/admin/conversazioni/ConversationsView.tsx
index e3f6e6f..c5644ee 100644
--- a/src/components/admin/conversazioni/ConversationsView.tsx
+++ b/src/components/admin/conversazioni/ConversationsView.tsx
@@ -1,6 +1,6 @@
"use client";
-import { useEffect, useMemo, useRef, useState } from "react";
+import { useEffect, useMemo, useRef, useState, useTransition } from "react";
import Link from "next/link";
import { useRouter } from "next/navigation";
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
import { Textarea } from "@/components/ui/textarea";
import { cn } from "@/lib/utils";
import {
+ editConversationMessage,
markConversationRead,
replyToConversation,
} from "@/app/admin/conversazioni/actions";
@@ -274,7 +275,9 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
Nessun messaggio in questo canale.
);
diff --git a/src/components/client/ChatPanel.tsx b/src/components/client/ChatPanel.tsx
index d76ba1b..de62439 100644
--- a/src/components/client/ChatPanel.tsx
+++ b/src/components/client/ChatPanel.tsx
@@ -73,6 +73,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
toggleExpanded,
} = useChatContext();
const preview = usePreview();
+ const { adminName, adminAvatarUrl } = chat;
const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]);
const index = useMemo(() => buildChannelIndex(clientId, phases), [clientId, phases]);
@@ -103,6 +104,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
// ── Non letti per canale ────────────────────────────────────────────────────
// Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando
// il cliente l'ha guardato. Senza ricevuta di lettura vale come mai letto.
+ //
+ // Si guarda created_at e NON edited_at, di proposito: correggere un refuso in
+ // un messaggio già letto non deve far riaccendere il pallino, altrimenti ogni
+ // correzione somiglia a un messaggio nuovo. Non è una svista da "sistemare".
const unreadChannels = useMemo(() => {
const lastAdminAt = new Map();
for (const m of messages) {
@@ -135,10 +140,20 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
// ── Polling ────────────────────────────────────────────────────────────────
// `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio
// nuovo smonterebbe e rimonterebbe l'intervallo, rimettendo il timer a zero.
+ //
+ // Il watermark è il massimo fra created_at ed edited_at su TUTTI i messaggi,
+ // non il created_at dell'ultimo: una modifica arriva dal server perché il
+ // filtro guarda anche edited_at, ma se poi il watermark restasse indietro il
+ // server continuerebbe a rispedire lo stesso messaggio a ogni giro, per sempre.
+ // I messaggi non sono ordinati per edited_at, quindi va scorso l'intero elenco.
const sinceRef = useRef(null);
useEffect(() => {
- const latest = messages[messages.length - 1];
- sinceRef.current = latest ? toDate(latest.created_at).toISOString() : null;
+ let max = 0;
+ for (const m of messages) {
+ max = Math.max(max, toDate(m.created_at).getTime());
+ if (m.edited_at) max = Math.max(max, toDate(m.edited_at).getTime());
+ }
+ sinceRef.current = max > 0 ? new Date(max).toISOString() : null;
}, [messages]);
useEffect(() => {
@@ -273,6 +288,45 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
[token, clientId]
);
+ // ── Modifica ───────────────────────────────────────────────────────────────
+ // Ottimistica sfruttando il merge già in piedi: `polled` vince su `chat.messages`
+ // a parità di id, quindi per far comparire subito il testo nuovo basta spingerci
+ // dentro una copia. Se il PATCH fallisce si rispinge l'originale e il feed torna
+ // com'era — senza uno stato "in modifica" separato da riconciliare.
+ const applyEdit = useCallback(
+ async (id: string, newBody: string): Promise => {
+ const original = messages.find((m) => m.id === id);
+ if (!original) return false;
+
+ setPolled((prev) => [
+ ...prev,
+ { ...original, body: newBody, edited_at: new Date().toISOString() },
+ ]);
+
+ try {
+ const res = await fetch("/api/client/comment", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ token, comment_id: id, body: newBody }),
+ });
+ if (!res.ok) {
+ setPolled((prev) => [...prev, original]);
+ const data = await res.json().catch(() => ({}));
+ setError(data.error ?? "Modifica non riuscita");
+ return false;
+ }
+ const data = (await res.json()) as { comment?: ChatMessage };
+ if (data.comment) setPolled((prev) => [...prev, data.comment!]);
+ return true;
+ } catch {
+ setPolled((prev) => [...prev, original]);
+ setError("Errore di rete");
+ return false;
+ }
+ },
+ [messages, token]
+ );
+
function handleSubmit(e: React.FormEvent) {
e.preventDefault();
const trimmed = body.trim();
@@ -439,6 +493,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
index={index}
activeChannel={activeChannel}
clientId={clientId}
+ adminName={adminName}
+ adminAvatarUrl={adminAvatarUrl}
+ canEdit={!preview}
+ onEdit={applyEdit}
/>
)}
@@ -545,11 +603,20 @@ function MessageList({
index,
activeChannel,
clientId,
+ adminName,
+ adminAvatarUrl,
+ canEdit,
+ onEdit,
}: {
messages: ChatMessage[];
index: ReturnType;
activeChannel: string;
clientId: string;
+ adminName: string;
+ adminAvatarUrl: string | null;
+ /** Falso in anteprima admin: si guarda, non si scrive. */
+ canEdit: boolean;
+ onEdit: (id: string, body: string) => Promise;
}) {
// Divider di giornata e raggruppamento si calcolano in un passaggio solo,
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
@@ -571,67 +638,235 @@ function MessageList({
return (
<>
- {items.map(({ message: m, at, showDivider, grouped }) => {
- const isClient = m.author === "client";
- const name = isClient ? "Tu" : "iamcavalli";
- // Dentro un canale-fase, i messaggi storici su un task o un deliverable
- // restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
- const entityLabel =
- activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined;
-
- return (
-
+ );
+}
+
+function Avatar({
+ label,
+ tone,
+ src,
+}: {
+ label: string;
+ tone: "client" | "admin";
+ src?: string | null;
+}) {
+ // Un URL esterno può sparire o essere sbagliato: in quel caso si torna al
+ // monogramma invece di lasciare un cerchio vuoto.
+ const [broken, setBroken] = useState(false);
+ const showImage = !!src && !broken;
+
return (
- {monogram(label)}
+ {showImage ? (
+ // next/image richiederebbe di dichiarare gli host in next.config: qui
+ // l'URL lo incolla l'utente e non è noto in fase di build.
+ // eslint-disable-next-line @next/next/no-img-element
+ setBroken(true)}
+ />
+ ) : (
+ monogram(label)
+ )}
);
}
diff --git a/src/db/migrations/0022_comment_edits.sql b/src/db/migrations/0022_comment_edits.sql
new file mode 100644
index 0000000..cd6ba4e
--- /dev/null
+++ b/src/db/migrations/0022_comment_edits.sql
@@ -0,0 +1,21 @@
+-- Additive: modifica di un messaggio già inviato, in chat (portale + inbox admin).
+--
+-- Modello Slack/Discord: si corregge un proprio messaggio senza limite di tempo,
+-- il testo precedente NON viene conservato, e accanto all'ora compare «modificato».
+-- È una scelta deliberata e non una svista — vedi STATUS.md: un messaggio scritto
+-- mesi fa resta riscrivibile, e l'unica difesa dell'altra parte è quell'etichetta.
+-- Se un domani servisse dimostrare cosa era stato scritto, la strada è una tabella
+-- `comment_edits` con lo storico: additiva, si aggiunge senza toccare questa colonna.
+--
+-- NULL = mai modificato. Nessun backfill: i messaggi esistenti non sono stati
+-- modificati, e scriverci dentro un timestamp finto li marcherebbe tutti come tali.
+--
+-- Perché serve una colonna e non basta l'update del body: il poll della chat chiede
+-- i messaggi con `created_at > since`, e una modifica NON cambia `created_at`. Senza
+-- un secondo timestamp su cui filtrare, l'altra parte continuerebbe a vedere il testo
+-- vecchio fino a un ricaricamento completo della pagina.
+--
+-- Apply to prod via SSH+docker exec BEFORE pushing schema-dependent code.
+-- No drops, no truncates, no data loss.
+
+ALTER TABLE comments ADD COLUMN IF NOT EXISTS edited_at timestamptz;
diff --git a/src/db/schema.ts b/src/db/schema.ts
index 01f3567..9630360 100644
--- a/src/db/schema.ts
+++ b/src/db/schema.ts
@@ -176,6 +176,11 @@ export const comments = pgTable("comments", {
created_at: timestamp("created_at", { withTimezone: true })
.notNull()
.defaultNow(),
+ // NULL = mai modificato. Il body viene sovrascritto e il testo precedente non
+ // si conserva (modello Slack/Discord): qui resta solo l'istante, che serve a
+ // due cose — l'etichetta «modificato» e il filtro del poll, che senza questo
+ // non avrebbe modo di accorgersi di una modifica (created_at non cambia).
+ edited_at: timestamp("edited_at", { withTimezone: true }),
});
// Fin dove il CLIENTE ha letto ciascun canale della chat. Una riga per
diff --git a/src/lib/chat-channels.ts b/src/lib/chat-channels.ts
index 59b1ef4..158b0b7 100644
--- a/src/lib/chat-channels.ts
+++ b/src/lib/chat-channels.ts
@@ -119,6 +119,8 @@ export type ChatMessage = {
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. */
@@ -128,4 +130,12 @@ export type ChatData = {
messages: ChatMessage[];
/** channel_key -> ISO dell'ultima lettura del cliente. */
reads: Record;
+ /**
+ * 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;
};
diff --git a/src/lib/client-view.ts b/src/lib/client-view.ts
index 9975041..1e0d21c 100644
--- a/src/lib/client-view.ts
+++ b/src/lib/client-view.ts
@@ -127,6 +127,8 @@ export interface ProjectView {
author: string;
body: string;
created_at: Date;
+ /** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
+ edited_at: Date | null;
}>;
/**
* Fin dove il cliente ha letto ciascun canale della chat: channel_key -> ISO.
diff --git a/src/lib/conversations-queries.ts b/src/lib/conversations-queries.ts
index 07c9fb4..9a88f89 100644
--- a/src/lib/conversations-queries.ts
+++ b/src/lib/conversations-queries.ts
@@ -26,6 +26,8 @@ export type ThreadMessage = {
author: "client" | "admin";
body: string;
created_at: Date;
+ /** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
+ edited_at: Date | null;
entityLabel: string;
entityType: string;
/**
@@ -276,6 +278,7 @@ export async function getConversationThread(
author: c.author as "client" | "admin",
body: c.body,
created_at: c.created_at,
+ edited_at: c.edited_at,
entityLabel: info?.label ?? "Generale",
entityType: info?.type ?? "general",
// Entità sparita (fase cancellata): il messaggio riemerge in Generale
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index adbf4b6..49f7c5e 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -5,8 +5,32 @@ import { revalidatePath } from "next/cache";
export const SETTINGS_KEYS = {
TARGET_HOURLY_RATE: "target_hourly_rate",
+ ADMIN_DISPLAY_NAME: "admin_display_name",
+ ADMIN_AVATAR_URL: "admin_avatar_url",
} as const;
+/**
+ * Il nome con cui il consulente si firma nella chat del portale, non il marchio.
+ * Il default è "iamcavalli" perché è il comportamento che c'era prima: un DB
+ * senza queste righe deve continuare a funzionare come oggi, non mostrare vuoto.
+ */
+export const ADMIN_NAME_FALLBACK = "iamcavalli";
+
+export async function getAdminIdentity(): Promise<{
+ name: string;
+ avatarUrl: string | null;
+}> {
+ const [name, avatarUrl] = await Promise.all([
+ getSetting(SETTINGS_KEYS.ADMIN_DISPLAY_NAME),
+ getSetting(SETTINGS_KEYS.ADMIN_AVATAR_URL),
+ ]);
+ return {
+ name: name?.trim() || ADMIN_NAME_FALLBACK,
+ // Stringa vuota = campo svuotato dal pannello: vale quanto "mai impostata".
+ avatarUrl: avatarUrl?.trim() || null,
+ };
+}
+
export async function getSetting(key: string): Promise {
const rows = await db
.select({ value: settings.value })