diff --git a/src/app/admin/conversazioni/actions.ts b/src/app/admin/conversazioni/actions.ts index eee0c0c..567e4e9 100644 --- a/src/app/admin/conversazioni/actions.ts +++ b/src/app/admin/conversazioni/actions.ts @@ -72,6 +72,60 @@ export async function replyToConversation(clientId: string, formData: FormData) revalidatePath(`/admin/clients/${clientId}`); } +const editSchema = z.object({ + comment_id: z.string().min(1), + body: z.string().trim().min(1, "Il messaggio non può essere vuoto").max(2000), +}); + +/** + * Modifica di un messaggio già inviato dall'admin. + * + * Nessun limite di tempo (modello Slack/Discord), ma si modifica solo ciò di cui + * si è autori: l'admin non riscrive i messaggi del cliente. L'admin è un utente + * unico e fidato, ma il controllo di appartenenza resta comunque — un commentId + * arbitrario nel form non deve poter scrivere nel thread di un altro cliente. + * + * A differenza di replyToConversation, NON tocca admin_last_read_at: correggere + * un refuso non è leggere la conversazione. + */ +export async function editConversationMessage( + clientId: string, + formData: FormData +) { + await requireAdmin(); + + const parsed = editSchema.safeParse({ + comment_id: formData.get("comment_id"), + body: formData.get("body"), + }); + if (!clientId || !parsed.success) throw new Error("Dati mancanti"); + const { comment_id, body } = parsed.data; + + const [existing] = await db + .select() + .from(comments) + .where(eq(comments.id, comment_id)) + .limit(1); + if (!existing || existing.author !== "admin") { + throw new Error("Messaggio non modificabile"); + } + + const owns = await assertClientOwnsEntity( + clientId, + existing.entity_type as "general" | "phase" | "task" | "deliverable", + existing.entity_id + ); + if (!owns) throw new Error("Messaggio non appartenente a questo cliente"); + + await db + .update(comments) + .set({ body, edited_at: new Date() }) + .where(eq(comments.id, comment_id)); + + revalidatePath("/admin/conversazioni"); + revalidatePath(`/admin/clients/${clientId}`); +} + /** Mark a client's conversation as read up to now (clears unread dot/badge). */ export async function markConversationRead(clientId: string) { await requireAdmin(); diff --git a/src/app/admin/impostazioni/page.tsx b/src/app/admin/impostazioni/page.tsx index dbaa2e4..6d520f0 100644 --- a/src/app/admin/impostazioni/page.tsx +++ b/src/app/admin/impostazioni/page.tsx @@ -1,4 +1,10 @@ -import { getTargetHourlyRate, updateSetting, SETTINGS_KEYS } from "@/lib/settings"; +import { + getTargetHourlyRate, + getAdminIdentity, + updateSetting, + SETTINGS_KEYS, + ADMIN_NAME_FALLBACK, +} from "@/lib/settings"; import { getAllPools } from "@/lib/taxonomy"; import { TaxonomyManager } from "@/components/admin/impostazioni/TaxonomyManager"; import { PageHeader } from "@/components/admin/PageHeader"; @@ -6,7 +12,11 @@ import { PageHeader } from "@/components/admin/PageHeader"; export const revalidate = 0; export default async function ImpostazioniPage() { - const [targetRate, pools] = await Promise.all([getTargetHourlyRate(), getAllPools()]); + const [targetRate, pools, admin] = await Promise.all([ + getTargetHourlyRate(), + getAllPools(), + getAdminIdentity(), + ]); async function handleSave(fd: FormData) { "use server"; @@ -16,6 +26,19 @@ export default async function ImpostazioniPage() { await updateSetting(SETTINGS_KEYS.TARGET_HOURLY_RATE, val.toFixed(2)); } + async function handleSaveIdentity(fd: FormData) { + "use server"; + const name = String(fd.get("admin_display_name") ?? "").trim(); + const avatar = String(fd.get("admin_avatar_url") ?? "").trim(); + + // Un URL che non è http(s) non verrebbe mai caricato dal browser: meglio non + // scriverlo affatto che salvarlo e lasciare l'avatar rotto senza spiegazione. + const validAvatar = /^https?:\/\//i.test(avatar) ? avatar : ""; + + await updateSetting(SETTINGS_KEYS.ADMIN_DISPLAY_NAME, name); + await updateSetting(SETTINGS_KEYS.ADMIN_AVATAR_URL, validAvatar); + } + return (
+
+
+

+ Firma nella chat +

+

+ Come ti vede il cliente quando rispondi nel portale. Senza nome resta + «{ADMIN_NAME_FALLBACK}», che è il marchio e non una persona. Senza foto + resta il monogramma delle iniziali. +

+ +
+
+ {/* Anteprima: se il link è rotto te ne accorgi qui, non dal cliente. */} + {admin.avatarUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + Anteprima della foto profilo + ) : ( + + {admin.name.slice(0, 2).toUpperCase()} + + )} + +
+
+ + +
+ +
+ + +
+
+
+ + +
+
+
+
); diff --git a/src/app/api/client/chat/route.ts b/src/app/api/client/chat/route.ts index b763329..c83cf80 100644 --- a/src/app/api/client/chat/route.ts +++ b/src/app/api/client/chat/route.ts @@ -1,5 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; -import { and, eq, gt, inArray, asc } from "drizzle-orm"; +import { and, eq, gt, inArray, asc, or } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; import { comments, client_channel_reads } from "@/db/schema"; @@ -61,11 +61,26 @@ export async function GET(request: NextRequest) { const validSince = sinceDate && !Number.isNaN(sinceDate.getTime()) ? sinceDate : null; + // Un messaggio è "nuovo per il pannello" se è stato scritto DOPPURE modificato + // dopo `since`: una modifica non tocca `created_at`, quindi filtrando solo su + // quello il testo corretto non arriverebbe mai all'altra parte. Con `edited_at` + // a NULL il confronto è NULL — i messaggi mai modificati non vengono ripescati, + // quindi il caso normale non paga nulla. const scope = inArray(comments.entity_id, entityIds); const rows = await db .select() .from(comments) - .where(validSince ? and(scope, gt(comments.created_at, validSince)) : scope) + .where( + validSince + ? and( + scope, + or( + gt(comments.created_at, validSince), + gt(comments.edited_at, validSince) + ) + ) + : scope + ) .orderBy(asc(comments.created_at)); const readRows = await db diff --git a/src/app/api/client/comment/route.ts b/src/app/api/client/comment/route.ts index 8ed19c1..50d21d8 100644 --- a/src/app/api/client/comment/route.ts +++ b/src/app/api/client/comment/route.ts @@ -1,4 +1,5 @@ import { NextRequest, NextResponse } from "next/server"; +import { eq } from "drizzle-orm"; import { z } from "zod"; import { db } from "@/db"; import { comments } from "@/db/schema"; @@ -12,6 +13,12 @@ const commentSchema = z.object({ body: z.string().min(1, "Il commento non può essere vuoto").max(2000), }); +const editSchema = z.object({ + token: z.string().min(1), + comment_id: z.string().min(1), + body: z.string().trim().min(1, "Il commento non può essere vuoto").max(2000), +}); + export async function POST(request: NextRequest) { const ip = request.headers.get("x-forwarded-for") ?? "unknown"; if (!rateLimit(`comment:${ip}`, 10, 60_000)) { @@ -59,3 +66,67 @@ export async function POST(request: NextRequest) { return NextResponse.json({ error: "Errore interno" }, { status: 500 }); } } + +/** + * Modifica di un messaggio già inviato dal cliente. + * + * Nessun limite di tempo (modello Slack/Discord), ma un limite netto su CHI: + * si modifica solo un messaggio di cui si è autori. Il controllo su `author` + * non è ridondante rispetto a quello sull'entità — senza, il cliente potrebbe + * riscrivere le risposte dell'admin nella propria chat, che è peggio del non + * poter modificare affatto. + */ +export async function PATCH(request: NextRequest) { + const ip = request.headers.get("x-forwarded-for") ?? "unknown"; + if (!rateLimit(`comment-edit:${ip}`, 10, 60_000)) { + return NextResponse.json({ error: "Troppe richieste" }, { status: 429 }); + } + + try { + const parsed = editSchema.safeParse(await request.json()); + if (!parsed.success) { + return NextResponse.json( + { error: parsed.error.issues[0].message }, + { status: 400 } + ); + } + const { token, comment_id, body: newBody } = parsed.data; + + const clientId = await resolveClientByToken(token); + if (!clientId) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + const [existing] = await db + .select() + .from(comments) + .where(eq(comments.id, comment_id)) + .limit(1); + + // Stesso 403 per "non esiste" e "non è tuo": distinguerli direbbe a chi prova + // id a caso quali esistono. + if (!existing || existing.author !== "client") { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + const owns = await assertClientOwnsEntity( + clientId, + existing.entity_type as "general" | "phase" | "task" | "deliverable", + existing.entity_id + ); + if (!owns) { + return NextResponse.json({ error: "Accesso non consentito" }, { status: 403 }); + } + + const [updated] = await db + .update(comments) + .set({ body: newBody, edited_at: new Date() }) + .where(eq(comments.id, comment_id)) + .returning(); + + return NextResponse.json({ success: true, comment: updated }); + } catch (err) { + console.error("/api/client/comment PATCH error:", err); + return NextResponse.json({ error: "Errore interno" }, { status: 500 }); + } +} diff --git a/src/app/client/[token]/page.tsx b/src/app/client/[token]/page.tsx index 2805a8b..f0892b7 100644 --- a/src/app/client/[token]/page.tsx +++ b/src/app/client/[token]/page.tsx @@ -13,6 +13,7 @@ import { OtpGate } from "@/components/client/OtpGate"; import { PreviewBanner } from "@/components/client/PreviewBanner"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { normalizeTaskStatus } from "@/lib/task-status"; +import { getAdminIdentity } from "@/lib/settings"; export const revalidate = 0; @@ -136,6 +137,10 @@ export default async function ClientPage({ ); } + // Come si firma chi risponde in chat. Una lettura sola per pagina, condivisa + // da tutti i progetti: è la stessa persona in ogni tab. + const admin = await getAdminIdentity(); + if (projects.length === 1) { // D-09: single project → direct view without selector const view = await getProjectView(projects[0].id); @@ -150,6 +155,8 @@ export default async function ClientPage({ projectId: view.project.id, messages: view.comments, reads: view.channel_reads, + adminName: admin.name, + adminAvatarUrl: admin.avatarUrl, }} preview={preview} /> @@ -200,6 +207,8 @@ export default async function ClientPage({ projectId: view.project.id, messages: view.comments, reads: view.channel_reads, + adminName: admin.name, + adminAvatarUrl: admin.avatarUrl, }} embedded preview={preview} diff --git a/src/components/admin/conversazioni/ConversationsView.tsx b/src/components/admin/conversazioni/ConversationsView.tsx index e3f6e6f..c5644ee 100644 --- a/src/components/admin/conversazioni/ConversationsView.tsx +++ b/src/components/admin/conversazioni/ConversationsView.tsx @@ -1,6 +1,6 @@ "use client"; -import { useEffect, useMemo, useRef, useState } from "react"; +import { useEffect, useMemo, useRef, useState, useTransition } from "react"; import Link from "next/link"; import { useRouter } from "next/navigation"; import { ExternalLink, IdCard, MessageSquare } from "lucide-react"; @@ -9,6 +9,7 @@ import { Button } from "@/components/ui/button"; import { Textarea } from "@/components/ui/textarea"; import { cn } from "@/lib/utils"; import { + editConversationMessage, markConversationRead, replyToConversation, } from "@/app/admin/conversazioni/actions"; @@ -274,7 +275,9 @@ function ActiveThread({ thread }: { thread: ConversationThread }) { Nessun messaggio in questo canale.

) : ( - visibleMessages.map((m) => ) + visibleMessages.map((m) => ( + + )) )} @@ -303,43 +306,143 @@ 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 ( -
-
-
- - {isAdmin ? "Tu (Admin)" : "Cliente"} - - {showEntity && } -
-

{message.body}

-

+

+
- {formatMessageTime(message.created_at)} -

+
+ + {isAdmin ? "Tu (Admin)" : "Cliente"} + + {showEntity && } +
+ + {editing ? ( +