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:
@@ -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();
|
||||
|
||||
@@ -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'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>
|
||||
);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
|
||||
Reference in New Issue
Block a user