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>
This commit is contained in:
2026-08-21 22:42:48 +02:00
parent 0d7186ba03
commit 94f4a54248
13 changed files with 735 additions and 82 deletions
+54
View File
@@ -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();
+103 -2
View File
@@ -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 (
<div className="space-y-8">
<PageHeader
@@ -63,6 +86,84 @@ export default async function ImpostazioniPage() {
</div>
</section>
<section className="rounded-xl border border-border-light bg-card p-6 shadow-card">
<div className="max-w-2xl">
<h2 className="mb-1 text-xs font-bold uppercase tracking-wider text-foreground">
Firma nella chat
</h2>
<p className="mb-6 text-xs text-tertiary">
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.
</p>
<form action={handleSaveIdentity} className="space-y-4">
<div className="flex items-center gap-4">
{/* Anteprima: se il link è rotto te ne accorgi qui, non dal cliente. */}
{admin.avatarUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={admin.avatarUrl}
alt="Anteprima della foto profilo"
className="h-12 w-12 shrink-0 rounded-full border border-border object-cover"
/>
) : (
<span className="flex h-12 w-12 shrink-0 items-center justify-center rounded-full bg-primary text-xs font-bold text-primary-foreground">
{admin.name.slice(0, 2).toUpperCase()}
</span>
)}
<div className="flex-1 space-y-3">
<div>
<label
htmlFor="admin_display_name"
className="mb-1 block text-[11px] font-medium text-tertiary"
>
Nome visualizzato
</label>
<input
id="admin_display_name"
name="admin_display_name"
type="text"
maxLength={60}
defaultValue={
admin.name === ADMIN_NAME_FALLBACK ? "" : admin.name
}
placeholder={ADMIN_NAME_FALLBACK}
className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 text-xs text-foreground transition-all duration-150 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
<div>
<label
htmlFor="admin_avatar_url"
className="mb-1 block text-[11px] font-medium text-tertiary"
>
Foto profilo indirizzo di un&apos;immagine già online
</label>
<input
id="admin_avatar_url"
name="admin_avatar_url"
type="url"
inputMode="url"
defaultValue={admin.avatarUrl ?? ""}
placeholder="https://…"
className="w-full rounded-lg border border-border bg-transparent px-3 py-2.5 font-mono text-xs text-foreground transition-all duration-150 focus:border-primary focus:outline-none focus:ring-1 focus:ring-primary"
/>
</div>
</div>
</div>
<button
type="submit"
className="rounded-lg bg-primary px-5 py-2.5 text-xs font-medium text-primary-foreground shadow-sm transition-colors hover:bg-primary/90"
>
Salva
</button>
</form>
</div>
</section>
<TaxonomyManager pools={pools} />
</div>
);
+17 -2
View File
@@ -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
+71
View File
@@ -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 });
}
}
+9
View File
@@ -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}
@@ -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.
</p>
) : (
visibleMessages.map((m) => <MessageBubble key={m.id} message={m} />)
visibleMessages.map((m) => (
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
))
)}
</div>
@@ -303,18 +306,52 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
function MessageBubble({
message,
clientId,
}: {
message: ConversationThread["messages"][number];
clientId: string;
}) {
const isAdmin = message.author === "admin";
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
const showEntity =
message.entityType === "task" || message.entityType === "deliverable";
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(message.body);
const [failed, setFailed] = useState(false);
const [saving, startSaving] = useTransition();
function startEditing() {
setDraft(message.body);
setFailed(false);
setEditing(true);
}
function save() {
const next = draft.trim();
if (!next || next === message.body) {
setEditing(false);
return;
}
const fd = new FormData();
fd.set("comment_id", message.id);
fd.set("body", next);
startSaving(async () => {
try {
await editConversationMessage(clientId, fd);
setEditing(false);
} catch {
setFailed(true);
}
});
}
return (
<div className={cn("flex", isAdmin && "justify-end")}>
<div className={cn("group flex", isAdmin && "justify-end")}>
<div className="max-w-[75%]">
<div
className={cn(
"max-w-[75%] p-3.5 rounded-2xl shadow-sm space-y-1",
"p-3.5 rounded-2xl shadow-sm space-y-1",
isAdmin
? "bg-primary text-primary-foreground rounded-tr-sm"
: "bg-background border border-border text-foreground rounded-tl-sm"
@@ -331,7 +368,33 @@ function MessageBubble({
</span>
{showEntity && <EntityBadge label={message.entityLabel} />}
</div>
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
{editing ? (
<Textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
save();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
rows={3}
autoFocus
maxLength={2000}
aria-label="Modifica il messaggio"
className="resize-none text-xs text-foreground bg-background"
/>
) : (
<p className="text-xs leading-relaxed whitespace-pre-wrap">
{message.body}
</p>
)}
<p
className={cn(
"text-[9px] font-mono",
@@ -339,8 +402,48 @@ function MessageBubble({
)}
>
{formatMessageTime(message.created_at)}
{message.edited_at && " · modificato"}
</p>
</div>
{/* Fuori dalla bolla: sopra `bg-primary` un testo tenue non si leggerebbe. */}
{isAdmin && (
<div className={cn("mt-1 flex items-center gap-3", "justify-end")}>
{editing ? (
<>
<button
type="button"
onClick={save}
disabled={saving}
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
>
{saving ? "Salvo…" : "Salva"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-[11px] font-medium text-muted-foreground hover:underline"
>
Annulla
</button>
{failed && (
<span className="text-[10px] text-destructive">
Non salvato, riprova
</span>
)}
</>
) : (
<button
type="button"
onClick={startEditing}
className="text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
>
Modifica
</button>
)}
</div>
)}
</div>
</div>
);
}
+253 -18
View File
@@ -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<string, number>();
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<string | null>(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<boolean> => {
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<typeof buildChannelIndex>;
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<boolean>;
}) {
// Divider di giornata e raggruppamento si calcolano in un passaggio solo,
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
@@ -571,16 +638,89 @@ function MessageList({
return (
<>
{items.map(({ message: m, at, showDivider, grouped }) => {
const isClient = m.author === "client";
const name = isClient ? "Tu" : "iamcavalli";
{items.map(({ message: m, at, showDivider, grouped }) => (
<MessageRow
key={m.id}
message={m}
at={at}
showDivider={showDivider}
grouped={grouped}
adminName={adminName}
adminAvatarUrl={adminAvatarUrl}
// 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;
entityLabel={
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined
}
// Si modifica solo ciò che si è scritto. Il server ricontrolla comunque:
// qui è solo per non mostrare un pulsante che verrebbe rifiutato.
canEdit={canEdit && m.author === "client"}
onEdit={onEdit}
/>
))}
</>
);
}
/**
* Una riga del feed. È un componente a sé — e non JSX inline dentro la .map() —
* perché la modifica ha bisogno di stato per messaggio: bozza, salvataggio in
* corso, errore. Tenerlo qui evita di risollevarlo nel genitore, dove sarebbe
* una mappa id -> stato da riconciliare a ogni poll.
*/
function MessageRow({
message: m,
at,
showDivider,
grouped,
entityLabel,
adminName,
adminAvatarUrl,
canEdit,
onEdit,
}: {
message: ChatMessage;
at: Date;
showDivider: boolean;
grouped: boolean;
entityLabel?: string;
adminName: string;
adminAvatarUrl: string | null;
canEdit: boolean;
onEdit: (id: string, body: string) => Promise<boolean>;
}) {
const [editing, setEditing] = useState(false);
const [draft, setDraft] = useState(m.body);
const [saving, setSaving] = useState(false);
const [failed, setFailed] = useState(false);
const isClient = m.author === "client";
const name = isClient ? "Tu" : adminName;
// La bozza si riempie all'apertura, non con un effetto sincronizzato su m.body:
// così un poll che arriva mentre stai scrivendo non ti cancella quello che hai
// digitato.
function startEditing() {
setDraft(m.body);
setFailed(false);
setEditing(true);
}
async function save() {
const next = draft.trim();
if (!next || next === m.body) {
setEditing(false);
return;
}
setSaving(true);
const ok = await onEdit(m.id, next);
setSaving(false);
if (ok) setEditing(false);
else setFailed(true);
}
return (
<div key={m.id}>
<div>
{showDivider && (
<div className="my-4 flex items-center gap-3">
<div className="h-px flex-1 bg-border" />
@@ -590,11 +730,15 @@ function MessageList({
<div className="h-px flex-1 bg-border" />
</div>
)}
<div className={cn("flex gap-3", grouped ? "mt-1" : "mt-5")}>
<div className={cn("group flex gap-3", grouped ? "mt-1" : "mt-5")}>
{grouped ? (
<div className="w-9 shrink-0" aria-hidden="true" />
) : (
<Avatar label={name} tone={isClient ? "client" : "admin"} />
<Avatar
label={name}
tone={isClient ? "client" : "admin"}
src={isClient ? null : adminAvatarUrl}
/>
)}
<div className="min-w-0 flex-1">
{!grouped && (
@@ -608,30 +752,121 @@ function MessageList({
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
</div>
)}
{editing ? (
<div className="mt-1">
<textarea
value={draft}
onChange={(e) => setDraft(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
void save();
}
if (e.key === "Escape") {
e.preventDefault();
setEditing(false);
}
}}
rows={2}
autoFocus
maxLength={2000}
aria-label="Modifica il messaggio"
className="w-full resize-none rounded-lg border border-border bg-background px-3 py-2 text-sm text-foreground focus:border-primary focus:outline-none"
/>
<div className="mt-1 flex items-center gap-3">
<button
type="button"
onClick={() => void save()}
disabled={saving}
className="text-[11px] font-semibold text-primary hover:underline disabled:opacity-50"
>
{saving ? "Salvo…" : "Salva"}
</button>
<button
type="button"
onClick={() => setEditing(false)}
className="text-[11px] font-medium text-muted-foreground hover:underline"
>
Annulla
</button>
<span className="text-[10px] text-muted-foreground">
Esc per annullare
</span>
{failed && (
<span className="text-[10px] text-destructive">
Non salvato, riprova
</span>
)}
</div>
</div>
) : (
<>
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
{m.body}
{m.edited_at && (
<span className="ml-1.5 text-[10px] text-muted-foreground">
(modificato)
</span>
)}
</p>
</div>
</div>
</div>
);
})}
{canEdit && (
<button
type="button"
onClick={startEditing}
// Compare al passaggio del mouse, ma resta raggiungibile da
// tastiera e sempre visibile dove il puntatore non esiste —
// il portale si apre soprattutto dal telefono.
className="mt-0.5 text-[11px] font-medium text-muted-foreground opacity-0 transition-opacity hover:underline focus-visible:opacity-100 group-hover:opacity-100 [@media(hover:none)]:opacity-100"
>
Modifica
</button>
)}
</>
)}
</div>
</div>
</div>
);
}
function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) {
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 (
<span
aria-hidden="true"
className={cn(
"flex h-9 w-9 shrink-0 items-center justify-center rounded-full text-[11px] font-bold",
"flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-full text-[11px] font-bold",
tone === "client"
? "bg-accent text-accent-foreground"
: "bg-primary text-primary-foreground"
)}
>
{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
<img
src={src as string}
alt=""
className="h-full w-full object-cover"
onError={() => setBroken(true)}
/>
) : (
monogram(label)
)}
</span>
);
}
+21
View File
@@ -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;
+5
View File
@@ -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
+10
View File
@@ -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<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;
};
+2
View File
@@ -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.
+3
View File
@@ -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
+24
View File
@@ -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<string | null> {
const rows = await db
.select({ value: settings.value })