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}`);
|
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). */
|
/** Mark a client's conversation as read up to now (clears unread dot/badge). */
|
||||||
export async function markConversationRead(clientId: string) {
|
export async function markConversationRead(clientId: string) {
|
||||||
await requireAdmin();
|
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 { getAllPools } from "@/lib/taxonomy";
|
||||||
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
|
import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager";
|
||||||
import { PageHeader } from "@/components/admin/PageHeader";
|
import { PageHeader } from "@/components/admin/PageHeader";
|
||||||
@@ -6,7 +12,11 @@ import { PageHeader } from "@/components/admin/PageHeader";
|
|||||||
export const revalidate = 0;
|
export const revalidate = 0;
|
||||||
|
|
||||||
export default async function ImpostazioniPage() {
|
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) {
|
async function handleSave(fd: FormData) {
|
||||||
"use server";
|
"use server";
|
||||||
@@ -16,6 +26,19 @@ export default async function ImpostazioniPage() {
|
|||||||
await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, val.toFixed(2));
|
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 (
|
return (
|
||||||
<div className="space-y-8">
|
<div className="space-y-8">
|
||||||
<PageHeader
|
<PageHeader
|
||||||
@@ -63,6 +86,84 @@ export default async function ImpostazioniPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</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} />
|
<TaxonomyManager pools={pools} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
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 { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { comments, client_channel_reads } from "@/db/schema";
|
import { comments, client_channel_reads } from "@/db/schema";
|
||||||
@@ -61,11 +61,26 @@ export async function GET(request: NextRequest) {
|
|||||||
const validSince =
|
const validSince =
|
||||||
sinceDate && !Number.isNaN(sinceDate.getTime()) ? sinceDate : null;
|
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 scope = inArray(comments.entity_id, entityIds);
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(comments)
|
.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));
|
.orderBy(asc(comments.created_at));
|
||||||
|
|
||||||
const readRows = await db
|
const readRows = await db
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { NextRequest, NextResponse } from "next/server";
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { eq } from "drizzle-orm";
|
||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { comments } from "@/db/schema";
|
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),
|
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) {
|
export async function POST(request: NextRequest) {
|
||||||
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
const ip = request.headers.get("x-forwarded-for") ?? "unknown";
|
||||||
if (!rateLimit(`comment:${ip}`, 10, 60_000)) {
|
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 });
|
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 { PreviewBanner } from "@/components/client/PreviewBanner";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { normalizeTaskStatus } from "@/lib/task-status";
|
import { normalizeTaskStatus } from "@/lib/task-status";
|
||||||
|
import { getAdminIdentity } from "@/lib/settings";
|
||||||
|
|
||||||
export const revalidate = 0;
|
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) {
|
if (projects.length === 1) {
|
||||||
// D-09: single project → direct view without selector
|
// D-09: single project → direct view without selector
|
||||||
const view = await getProjectView(projects[0].id);
|
const view = await getProjectView(projects[0].id);
|
||||||
@@ -150,6 +155,8 @@ export default async function ClientPage({
|
|||||||
projectId: view.project.id,
|
projectId: view.project.id,
|
||||||
messages: view.comments,
|
messages: view.comments,
|
||||||
reads: view.channel_reads,
|
reads: view.channel_reads,
|
||||||
|
adminName: admin.name,
|
||||||
|
adminAvatarUrl: admin.avatarUrl,
|
||||||
}}
|
}}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
/>
|
/>
|
||||||
@@ -200,6 +207,8 @@ export default async function ClientPage({
|
|||||||
projectId: view.project.id,
|
projectId: view.project.id,
|
||||||
messages: view.comments,
|
messages: view.comments,
|
||||||
reads: view.channel_reads,
|
reads: view.channel_reads,
|
||||||
|
adminName: admin.name,
|
||||||
|
adminAvatarUrl: admin.avatarUrl,
|
||||||
}}
|
}}
|
||||||
embedded
|
embedded
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useRef, useState } from "react";
|
import { useEffect, useMemo, useRef, useState, useTransition } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useRouter } from "next/navigation";
|
import { useRouter } from "next/navigation";
|
||||||
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
import { ExternalLink, IdCard, MessageSquare } from "lucide-react";
|
||||||
@@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button";
|
|||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
import {
|
import {
|
||||||
|
editConversationMessage,
|
||||||
markConversationRead,
|
markConversationRead,
|
||||||
replyToConversation,
|
replyToConversation,
|
||||||
} from "@/app/admin/conversazioni/actions";
|
} from "@/app/admin/conversazioni/actions";
|
||||||
@@ -274,7 +275,9 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
Nessun messaggio in questo canale.
|
Nessun messaggio in questo canale.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
visibleMessages.map((m) => <MessageBubble key={m.id} message={m} />)
|
visibleMessages.map((m) => (
|
||||||
|
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
|
||||||
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -303,43 +306,143 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
|
|
||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
|
clientId,
|
||||||
}: {
|
}: {
|
||||||
message: ConversationThread["messages"][number];
|
message: ConversationThread["messages"][number];
|
||||||
|
clientId: string;
|
||||||
}) {
|
}) {
|
||||||
const isAdmin = message.author === "admin";
|
const isAdmin = message.author === "admin";
|
||||||
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
|
// Solo task e deliverable: per una fase l'etichetta ripeterebbe il tab attivo.
|
||||||
const showEntity =
|
const showEntity =
|
||||||
message.entityType === "task" || message.entityType === "deliverable";
|
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 (
|
return (
|
||||||
<div className={cn("flex", isAdmin && "justify-end")}>
|
<div className={cn("group flex", isAdmin && "justify-end")}>
|
||||||
<div
|
<div className="max-w-[75%]">
|
||||||
className={cn(
|
<div
|
||||||
"max-w-[75%] 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"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span
|
|
||||||
className={cn(
|
|
||||||
"text-[10px] font-bold",
|
|
||||||
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{isAdmin ? "Tu (Admin)" : "Cliente"}
|
|
||||||
</span>
|
|
||||||
{showEntity && <EntityBadge label={message.entityLabel} />}
|
|
||||||
</div>
|
|
||||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">{message.body}</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-[9px] font-mono",
|
"p-3.5 rounded-2xl shadow-sm space-y-1",
|
||||||
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
|
isAdmin
|
||||||
|
? "bg-primary text-primary-foreground rounded-tr-sm"
|
||||||
|
: "bg-background border border-border text-foreground rounded-tl-sm"
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
{formatMessageTime(message.created_at)}
|
<div className="flex items-center gap-2">
|
||||||
</p>
|
<span
|
||||||
|
className={cn(
|
||||||
|
"text-[10px] font-bold",
|
||||||
|
isAdmin ? "text-primary-foreground/70" : "text-muted-foreground"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isAdmin ? "Tu (Admin)" : "Cliente"}
|
||||||
|
</span>
|
||||||
|
{showEntity && <EntityBadge label={message.entityLabel} />}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{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",
|
||||||
|
isAdmin ? "text-primary-foreground/50" : "text-muted-foreground/60"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -73,6 +73,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
|||||||
toggleExpanded,
|
toggleExpanded,
|
||||||
} = useChatContext();
|
} = useChatContext();
|
||||||
const preview = usePreview();
|
const preview = usePreview();
|
||||||
|
const { adminName, adminAvatarUrl } = chat;
|
||||||
|
|
||||||
const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]);
|
const channels = useMemo(() => buildChannels(clientId, phases), [clientId, phases]);
|
||||||
const index = useMemo(() => buildChannelIndex(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 ────────────────────────────────────────────────────
|
// ── Non letti per canale ────────────────────────────────────────────────────
|
||||||
// Un canale ha novità se l'ultimo messaggio dell'admin è più recente di quando
|
// 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.
|
// 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 unreadChannels = useMemo(() => {
|
||||||
const lastAdminAt = new Map<string, number>();
|
const lastAdminAt = new Map<string, number>();
|
||||||
for (const m of messages) {
|
for (const m of messages) {
|
||||||
@@ -135,10 +140,20 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
|||||||
// ── Polling ────────────────────────────────────────────────────────────────
|
// ── Polling ────────────────────────────────────────────────────────────────
|
||||||
// `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio
|
// `since` in una ref: se fosse una dipendenza dell'effetto, ogni messaggio
|
||||||
// nuovo smonterebbe e rimonterebbe l'intervallo, rimettendo il timer a zero.
|
// 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);
|
const sinceRef = useRef<string | null>(null);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const latest = messages[messages.length - 1];
|
let max = 0;
|
||||||
sinceRef.current = latest ? toDate(latest.created_at).toISOString() : null;
|
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]);
|
}, [messages]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -273,6 +288,45 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
|||||||
[token, clientId]
|
[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) {
|
function handleSubmit(e: React.FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
const trimmed = body.trim();
|
const trimmed = body.trim();
|
||||||
@@ -439,6 +493,10 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
|||||||
index={index}
|
index={index}
|
||||||
activeChannel={activeChannel}
|
activeChannel={activeChannel}
|
||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
|
adminName={adminName}
|
||||||
|
adminAvatarUrl={adminAvatarUrl}
|
||||||
|
canEdit={!preview}
|
||||||
|
onEdit={applyEdit}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
@@ -545,11 +603,20 @@ function MessageList({
|
|||||||
index,
|
index,
|
||||||
activeChannel,
|
activeChannel,
|
||||||
clientId,
|
clientId,
|
||||||
|
adminName,
|
||||||
|
adminAvatarUrl,
|
||||||
|
canEdit,
|
||||||
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
index: ReturnType<typeof buildChannelIndex>;
|
index: ReturnType<typeof buildChannelIndex>;
|
||||||
activeChannel: string;
|
activeChannel: string;
|
||||||
clientId: 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,
|
// Divider di giornata e raggruppamento si calcolano in un passaggio solo,
|
||||||
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
|
// prima del JSX: dentro una .map() servirebbero variabili riassegnate a ogni
|
||||||
@@ -571,67 +638,235 @@ function MessageList({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{items.map(({ message: m, at, showDivider, grouped }) => {
|
{items.map(({ message: m, at, showDivider, grouped }) => (
|
||||||
const isClient = m.author === "client";
|
<MessageRow
|
||||||
const name = isClient ? "Tu" : "iamcavalli";
|
key={m.id}
|
||||||
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
message={m}
|
||||||
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
at={at}
|
||||||
const entityLabel =
|
showDivider={showDivider}
|
||||||
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined;
|
grouped={grouped}
|
||||||
|
adminName={adminName}
|
||||||
return (
|
adminAvatarUrl={adminAvatarUrl}
|
||||||
<div key={m.id}>
|
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
||||||
{showDivider && (
|
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
||||||
<div className="my-4 flex items-center gap-3">
|
entityLabel={
|
||||||
<div className="h-px flex-1 bg-border" />
|
activeChannel !== clientId ? index.entityLabel.get(m.entity_id) : undefined
|
||||||
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
}
|
||||||
{formatDayDivider(at)}
|
// Si modifica solo ciò che si è scritto. Il server ricontrolla comunque:
|
||||||
</span>
|
// qui è solo per non mostrare un pulsante che verrebbe rifiutato.
|
||||||
<div className="h-px flex-1 bg-border" />
|
canEdit={canEdit && m.author === "client"}
|
||||||
</div>
|
onEdit={onEdit}
|
||||||
)}
|
/>
|
||||||
<div className={cn("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"} />
|
|
||||||
)}
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
{!grouped && (
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<span className="text-xs font-bold text-foreground">{name}</span>
|
|
||||||
{entityLabel && (
|
|
||||||
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
|
||||||
{entityLabel}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<span className="text-[10px] text-muted-foreground">{formatTime(at)}</span>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
|
||||||
{m.body}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Avatar({ label, tone }: { label: string; tone: "client" | "admin" }) {
|
/**
|
||||||
|
* 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>
|
||||||
|
{showDivider && (
|
||||||
|
<div className="my-4 flex items-center gap-3">
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
<span className="text-[10px] font-bold uppercase tracking-wider text-muted-foreground">
|
||||||
|
{formatDayDivider(at)}
|
||||||
|
</span>
|
||||||
|
<div className="h-px flex-1 bg-border" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<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"}
|
||||||
|
src={isClient ? null : adminAvatarUrl}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
{!grouped && (
|
||||||
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
|
<span className="text-xs font-bold text-foreground">{name}</span>
|
||||||
|
{entityLabel && (
|
||||||
|
<span className="max-w-[160px] truncate rounded-full bg-muted px-2 py-0.5 text-[10px] text-muted-foreground">
|
||||||
|
{entityLabel}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<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>
|
||||||
|
{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,
|
||||||
|
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 (
|
return (
|
||||||
<span
|
<span
|
||||||
aria-hidden="true"
|
aria-hidden="true"
|
||||||
className={cn(
|
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"
|
tone === "client"
|
||||||
? "bg-accent text-accent-foreground"
|
? "bg-accent text-accent-foreground"
|
||||||
: "bg-primary text-primary-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>
|
</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 })
|
created_at: timestamp("created_at", { withTimezone: true })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.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
|
// Fin dove il CLIENTE ha letto ciascun canale della chat. Una riga per
|
||||||
|
|||||||
@@ -119,6 +119,8 @@ export type ChatMessage = {
|
|||||||
author: string;
|
author: string;
|
||||||
body: string;
|
body: string;
|
||||||
created_at: Date | 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. */
|
/** Tutto ciò che il pannello riceve dal server al primo render. */
|
||||||
@@ -128,4 +130,12 @@ export type ChatData = {
|
|||||||
messages: ChatMessage[];
|
messages: ChatMessage[];
|
||||||
/** channel_key -> ISO dell'ultima lettura del cliente. */
|
/** channel_key -> ISO dell'ultima lettura del cliente. */
|
||||||
reads: Record<string, string>;
|
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;
|
author: string;
|
||||||
body: string;
|
body: string;
|
||||||
created_at: Date;
|
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.
|
* Fin dove il cliente ha letto ciascun canale della chat: channel_key -> ISO.
|
||||||
|
|||||||
@@ -26,6 +26,8 @@ export type ThreadMessage = {
|
|||||||
author: "client" | "admin";
|
author: "client" | "admin";
|
||||||
body: string;
|
body: string;
|
||||||
created_at: Date;
|
created_at: Date;
|
||||||
|
/** Valorizzato solo se il messaggio è stato modificato dopo l'invio. */
|
||||||
|
edited_at: Date | null;
|
||||||
entityLabel: string;
|
entityLabel: string;
|
||||||
entityType: string;
|
entityType: string;
|
||||||
/**
|
/**
|
||||||
@@ -276,6 +278,7 @@ export async function getConversationThread(
|
|||||||
author: c.author as "client" | "admin",
|
author: c.author as "client" | "admin",
|
||||||
body: c.body,
|
body: c.body,
|
||||||
created_at: c.created_at,
|
created_at: c.created_at,
|
||||||
|
edited_at: c.edited_at,
|
||||||
entityLabel: info?.label ?? "Generale",
|
entityLabel: info?.label ?? "Generale",
|
||||||
entityType: info?.type ?? "general",
|
entityType: info?.type ?? "general",
|
||||||
// Entità sparita (fase cancellata): il messaggio riemerge in Generale
|
// Entità sparita (fase cancellata): il messaggio riemerge in Generale
|
||||||
|
|||||||
@@ -5,8 +5,32 @@ import { revalidatePath } from "next/cache";
|
|||||||
|
|
||||||
export const SETTINGS_KEYS = {
|
export const SETTINGS_KEYS = {
|
||||||
TARGET_HOURLY_RATE: "target_hourly_rate",
|
TARGET_HOURLY_RATE: "target_hourly_rate",
|
||||||
|
ADMIN_DISPLAY_NAME: "admin_display_name",
|
||||||
|
ADMIN_AVATAR_URL: "admin_avatar_url",
|
||||||
} as const;
|
} 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> {
|
export async function getSetting(key: string): Promise<string | null> {
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select({ value: settings.value })
|
.select({ value: settings.value })
|
||||||
|
|||||||
Reference in New Issue
Block a user