Compare commits
4 Commits
817a8cd5d1
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9ce6f02ee4 | |||
| 614eb00040 | |||
| d71fede4e4 | |||
| 41530b556a |
@@ -16,6 +16,11 @@ INTERNAL_SECRET=generate-with-openssl-rand-base64-32
|
|||||||
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
RESEND_API_KEY=re_xxxxxxxxxxxxxxxxxxxxxxxx
|
||||||
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
|
RESEND_FROM=Nome Mittente <no-reply@iamcavalli.net>
|
||||||
|
|
||||||
|
# Base pubblica dei link nelle email (pulsante "Apri la conversazione" della
|
||||||
|
# notifica di tag). Opzionale: se manca si usa NEXTAUTH_URL, che in ogni
|
||||||
|
# ambiente e' gia' l'origine giusta. Serve solo se le due devono divergere.
|
||||||
|
# APP_BASE_URL=https://hub.iamcavalli.net
|
||||||
|
|
||||||
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
|
# Ingresso lead da fuori (form del sito, bridge Zapier/Make) su
|
||||||
# POST /api/webhooks/lead, header x-webhook-secret.
|
# POST /api/webhooks/lead, header x-webhook-secret.
|
||||||
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
|
# A differenza di INTERNAL_SECRET questa route e' esposta a internet: se la
|
||||||
|
|||||||
+8
-5
@@ -3,9 +3,9 @@ gsd_state_version: 1.0
|
|||||||
milestone: v2.5
|
milestone: v2.5
|
||||||
milestone_name: Audit
|
milestone_name: Audit
|
||||||
status: executing
|
status: executing
|
||||||
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date."
|
stopped_at: "v2.5 in PAUSA. Modifiche hub: A, B, C1, rifiniture, chat a canali (0021), modifica messaggi + firma (0022), stati task e date pagamenti (0023) in prod; C2 (TidyCal) bloccato sulle credenziali API. Conversazioni (scrittura per primo, menzioni, mail sul tag) pushata il 2026-09-01, nessuna migration, MAI provata a mano. Nulla del portale e' stato visto a schermo: .env.local non autentica piu', serve ?preview=1 in prod. Il riquadro 'Prossimo pagamento' non compare finche' nessuna rata ha una due_date."
|
||||||
last_updated: "2026-08-26T16:10:00.000Z"
|
last_updated: "2026-09-01T00:15:00.000Z"
|
||||||
last_activity: 2026-08-26 -- architettura .claude di hub: skill /preventivo e /audit, due hook di guardia, piani portati nel repo
|
last_activity: 2026-09-01 -- conversazioni: scrivere per primo, menzioni, mail sul tag. Pushata dopo aver recuperato l'accesso a gitea dalla CLI admin
|
||||||
progress:
|
progress:
|
||||||
total_phases: 4
|
total_phases: 4
|
||||||
completed_phases: 0
|
completed_phases: 0
|
||||||
@@ -39,7 +39,8 @@ Phase 27 resta a metà — schema e fonti in prod, resto da scrivere.
|
|||||||
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
| C3 — Alleggerire l'hub | ⏸️ senza perimetro |
|
||||||
| Rifiniture — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) |
|
| Rifiniture — tassonomie, tab pagamenti, riordino task | ✅ in prod 2026-08-20 (`0019`) |
|
||||||
| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker |
|
| Portale cliente — stepper compatto/full-width, card offerta | ✅ in prod 2026-08-21 (`0020`); override provato su Caruso Speaker |
|
||||||
| Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; menzioni rimandate, manca l'attribuzione |
|
| Chat — canali, modifica messaggi, firma admin | ✅ in prod 2026-08-21 (`0021`, `0022`); **mai provata a mano**; manca l'attribuzione |
|
||||||
|
| Conversazioni — scrivere per primo, menzioni `@Nome`, mail sul tag | ✅ pushata 2026-09-01 (`41530b5`), nessuna migration. Menu dei tag corretto lo stesso giorno: mostrava **una riga per alias** invece che per persona. Build + lint puliti, 23 test sul parser e sul menu; **nessuna mail di tag mai partita davvero** |
|
||||||
| Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita |
|
| Portale — stati task (forma, pill, legenda, «Cancellata») + date dei pagamenti | ✅ in prod 2026-08-22 (`2e9bd2a`, `8b54f48`, `fe76789`, migration `0023`); **mai visto a schermo**, nessuna `due_date` ancora inserita |
|
||||||
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
| D — Whop → audit | ⏸️ dipende dal motore v2.5 |
|
||||||
|
|
||||||
@@ -69,6 +70,8 @@ Passo per passo in `STATUS.md` e in `.claude/plans/v2.5-audit-motore.md`.
|
|||||||
|
|
||||||
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
Log completo in `PROJECT.md`. Vive per il lavoro corrente:
|
||||||
|
|
||||||
|
- **[2026-09-01] Una lista che serve a leggere non è una lista da offrire in scrittura** — il menu dei tag mostrava tre voci (nome, brand, nome di battesimo) per una persona sola, perché gli era stata passata la stessa lista che il parser usa per **riconoscere** un tag nel testo. Sono due domande diverse: *come lo si può scrivere* (tutti gli alias, invisibili) e *chi si può scegliere* (una riga per destinatario). Ora `MentionTarget` le tiene separate — `label` per il menu, `aliases` per la rilettura — e il filtro passa da `normalizeForSearch`, la stessa normalizzazione del parser: con un `toLowerCase()` a parte, digitare «nicolo» non troverebbe «Nicolò» nel menu mentre nel messaggio verrebbe riconosciuto. Il difetto scalava peggio del valore: con tre contatti sarebbero state nove righe.
|
||||||
|
- **[2026-09-01] L'accesso a Gitea si recupera dalla sua CLI, non dal web** — il push rispondeva 403 (la credenziale nel portachiavi leggeva ma non scriveva) e l'account web non era piu' accessibile, quindi la via del browser era chiusa in partenza. Si rientra da dentro il container: `docker exec -u git gitea-tgw04ws48sogkso84oogwckk gitea admin user change-password` per la password e `... generate-access-token --scopes write:repository --raw` per il token del push. Il token va messo nel portachiavi con `git credential approve`, **non** nell'URL del remote: li' finirebbe in chiaro dentro `.git/config`. Da ricordare perche' senza push non esiste deploy — Coolify parte da `main` e basta.
|
||||||
- **[2026-08-22] Un task cancellato esce dai denominatori, non dalla lista** — resta visibile barrato (il cliente ha letto quella voce e deve capire che fine ha fatto) ma non conta, via un solo `countsTowardProgress()` in `task-status.ts`: contarlo terrebbe la fase sotto il 100% per sempre, contarlo come fatto racconterebbe una consegna mai avvenuta. Se in una fase restano solo cancellati torna «Da iniziare»: degenere, ma «Completata» mentirebbe.
|
- **[2026-08-22] Un task cancellato esce dai denominatori, non dalla lista** — resta visibile barrato (il cliente ha letto quella voce e deve capire che fine ha fatto) ma non conta, via un solo `countsTowardProgress()` in `task-status.ts`: contarlo terrebbe la fase sotto il 100% per sempre, contarlo come fatto racconterebbe una consegna mai avvenuta. Se in una fase restano solo cancellati torna «Da iniziare»: degenere, ma «Completata» mentirebbe.
|
||||||
- **[2026-08-22] Le date dei pagamenti sì, gli importi per riga no** — LOCKED #2 parla di cifre, non di date. Salvate a **mezzogiorno UTC** (a mezzanotte il giorno civile a Roma è già quello dopo) e contate sui giorni civili a Roma in `src/lib/payment-dates.ts`, lo stesso modulo del futuro promemoria email: mail e portale non devono contraddirsi su quanti giorni mancano.
|
- **[2026-08-22] Le date dei pagamenti sì, gli importi per riga no** — LOCKED #2 parla di cifre, non di date. Salvate a **mezzogiorno UTC** (a mezzanotte il giorno civile a Roma è già quello dopo) e contate sui giorni civili a Roma in `src/lib/payment-dates.ts`, lo stesso modulo del futuro promemoria email: mail e portale non devono contraddirsi su quanti giorni mancano.
|
||||||
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
|
- **[2026-08-20] Gli importi scritti a mano non si ricalcolano** — `amount_locked` esclude la riga da `rescalePayments`, e lo scarto fra somma rate e totale si dichiara invece di aggiustarlo. Il backfill dell'ordine rate va per `percent DESC`, non per `ctid`: 3 progetti su 5 erano già scombinati e l'ordine fisico avrebbe fissato l'errore.
|
||||||
@@ -96,5 +99,5 @@ Last session: 2026-08-26
|
|||||||
Stopped at: **stato task «Cancellata» + date dei pagamenti nel portale** (`fe76789`, migration `0023` applicata a prod prima del push). Un task tolto dal lavoro ora ha dove stare: **X nel cerchio, titolo barrato**, pill «Cancellata» — l'unico stato chiuso con la pill, perché «Fatto» e «Cancellata» sono entrambi barrati e scambiarli significa credere consegnato ciò che non esiste. Esce da **tutti** i denominatori via `countsTowardProgress()`. Nel Kanban cliente la colonna compare solo se piena (mai nascosta se ha dentro qualcosa); nell'admin c'è sempre, ed è così che si cancella un task. Lato pagamenti: `payments.due_date` (nullable, più indice parziale per il futuro promemoria email), riquadro «Prossimo pagamento» con conto alla rovescia in parole e rosso se scaduto, «Scade il…» / «Pagato il…» su ogni riga, **zero importi**. Admin: campo Scadenza per rata, «Incassato nel mese» → «Incassato il» (giorno; le analytics raggruppano per mese e non se ne accorgono). Build, typecheck e lint verdi.
|
Stopped at: **stato task «Cancellata» + date dei pagamenti nel portale** (`fe76789`, migration `0023` applicata a prod prima del push). Un task tolto dal lavoro ora ha dove stare: **X nel cerchio, titolo barrato**, pill «Cancellata» — l'unico stato chiuso con la pill, perché «Fatto» e «Cancellata» sono entrambi barrati e scambiarli significa credere consegnato ciò che non esiste. Esce da **tutti** i denominatori via `countsTowardProgress()`. Nel Kanban cliente la colonna compare solo se piena (mai nascosta se ha dentro qualcosa); nell'admin c'è sempre, ed è così che si cancella un task. Lato pagamenti: `payments.due_date` (nullable, più indice parziale per il futuro promemoria email), riquadro «Prossimo pagamento» con conto alla rovescia in parole e rosso se scaduto, «Scade il…» / «Pagato il…» su ogni riga, **zero importi**. Admin: campo Scadenza per rata, «Incassato nel mese» → «Incassato il» (giorno; le analytics raggruppano per mese e non se ne accorgono). Build, typecheck e lint verdi.
|
||||||
**2026-08-26 — architettura `.claude/`**: skill `/preventivo` e `/audit`, due hook di guardia, piani nel repo. Nessun tocco al prodotto. Le due cose trovate e non risolte sul preventivo → `STATUS.md`.
|
**2026-08-26 — architettura `.claude/`**: skill `/preventivo` e `/audit`, due hook di guardia, piani nel repo. Nessun tocco al prodotto. Le due cose trovate e non risolte sul preventivo → `STATUS.md`.
|
||||||
|
|
||||||
Next: (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
|
Next: (0) **[SICUREZZA] rigenerare il token Gitea creato il 2026-09-01**: e' stato incollato in chat, quindi va considerato esposto. Si revoca da Settings -> Applications e si rifa'; (1) verificare in prod con `?preview=1` **entrambe** le cose: stati task su **Caruso Speaker, fase «3 - Esecuzione»** (l'unica con «In corso» e «In revisione» insieme, 4 + 2 su 11) e box pagamenti — ma prima **inserire una scadenza** dal tab Pagamenti, altrimenti il riquadro non compare per definizione; (2) due `paid_at` storici valgono il primo del mese (2026-03-01, 2026-01-01, scritti dal vecchio selettore a mese) e il cliente ora li legge come «Pagato il 1 mar 2026»: correggerli se il giorno vero era un altro; (3) provare la chat in prod: modificare un messaggio admin e vederlo cambiare da solo entro ~20s senza duplicarsi; (4) sbloccare TidyCal con token o documentazione; (5) `LEAD_WEBHOOK_SECRET` su Coolify, senza cui `/api/webhooks/lead` risponde 403 a tutti; (6) poi v2.5 da `src/lib/audit/schema.ts` + `agents/`.
|
||||||
Resume file: None
|
Resume file: None
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import { authOptions } from "@/lib/auth";
|
|||||||
import { db } from "@/db";
|
import { db } from "@/db";
|
||||||
import { clients, comments } from "@/db/schema";
|
import { clients, comments } from "@/db/schema";
|
||||||
import { assertClientOwnsEntity } from "@/lib/client-chat";
|
import { assertClientOwnsEntity } from "@/lib/client-chat";
|
||||||
|
import { hasMention, mentionCandidates } from "@/lib/mentions";
|
||||||
|
import { notifyClientMention } from "@/lib/client-notifications";
|
||||||
|
|
||||||
async function requireAdmin() {
|
async function requireAdmin() {
|
||||||
const session = await getServerSession(authOptions);
|
const session = await getServerSession(authOptions);
|
||||||
@@ -43,12 +45,12 @@ export async function replyToConversation(clientId: string, formData: FormData)
|
|||||||
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
|
if (!clientId || !parsed.success) throw new Error("Dati mancanti");
|
||||||
const { entity_type, entity_id, body } = parsed.data;
|
const { entity_type, entity_id, body } = parsed.data;
|
||||||
|
|
||||||
const rows = await db
|
const [client] = await db
|
||||||
.select({ id: clients.id })
|
.select({ id: clients.id, name: clients.name, brand_name: clients.brand_name })
|
||||||
.from(clients)
|
.from(clients)
|
||||||
.where(eq(clients.id, clientId))
|
.where(eq(clients.id, clientId))
|
||||||
.limit(1);
|
.limit(1);
|
||||||
if (rows.length === 0) throw new Error("Cliente non trovato");
|
if (!client) throw new Error("Cliente non trovato");
|
||||||
|
|
||||||
// Stessa verifica di appartenenza usata dalle route del portale: un entity_id
|
// Stessa verifica di appartenenza usata dalle route del portale: un entity_id
|
||||||
// arbitrario nel form non deve poter scrivere nella chat di un altro cliente.
|
// arbitrario nel form non deve poter scrivere nella chat di un altro cliente.
|
||||||
@@ -68,6 +70,17 @@ export async function replyToConversation(clientId: string, formData: FormData)
|
|||||||
.set({ admin_last_read_at: new Date() })
|
.set({ admin_last_read_at: new Date() })
|
||||||
.where(eq(clients.id, clientId));
|
.where(eq(clients.id, clientId));
|
||||||
|
|
||||||
|
// Il tag è l'unico messaggio che esce dal portale. Si valuta sul testo appena
|
||||||
|
// scritto, non su un campo nascosto del form: quello resterebbe indietro se
|
||||||
|
// l'admin cancellasse la menzione prima di inviare.
|
||||||
|
//
|
||||||
|
// L'invio non è dentro una transazione con l'insert e non deve esserlo: se la
|
||||||
|
// mail fallisce, il messaggio in chat resta comunque scritto — il contrario
|
||||||
|
// sarebbe perdere il messaggio per colpa del provider di posta.
|
||||||
|
if (hasMention(body, mentionCandidates(client))) {
|
||||||
|
await notifyClientMention({ clientId, channelKey: entity_id, body });
|
||||||
|
}
|
||||||
|
|
||||||
revalidatePath("/admin/conversazioni");
|
revalidatePath("/admin/conversazioni");
|
||||||
revalidatePath(`/admin/clients/${clientId}`);
|
revalidatePath(`/admin/clients/${clientId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,15 +98,31 @@ export async function generateMetadata({
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Il canale chiesto con `?chat=<key>`, se appartiene a QUESTO progetto.
|
||||||
|
*
|
||||||
|
* La validazione non e' cosmetica: senza, un id qualsiasi nella query aprirebbe
|
||||||
|
* la chat su un canale vuoto — e con piu' progetti a tab, la fase di un progetto
|
||||||
|
* spalancherebbe il pannello anche negli altri.
|
||||||
|
*/
|
||||||
|
function resolveChatChannel(
|
||||||
|
requested: string | undefined,
|
||||||
|
view: ProjectView
|
||||||
|
): string | null {
|
||||||
|
if (!requested) return null;
|
||||||
|
if (requested === view.project.client_id) return requested;
|
||||||
|
return view.phases.some((p) => p.id === requested) ? requested : null;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function ClientPage({
|
export default async function ClientPage({
|
||||||
params,
|
params,
|
||||||
searchParams,
|
searchParams,
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ token: string }>;
|
params: Promise<{ token: string }>;
|
||||||
searchParams: Promise<{ preview?: string }>;
|
searchParams: Promise<{ preview?: string; chat?: string }>;
|
||||||
}) {
|
}) {
|
||||||
const { token } = await params;
|
const { token } = await params;
|
||||||
const { preview: previewParam } = await searchParams;
|
const { preview: previewParam, chat: chatParam } = await searchParams;
|
||||||
|
|
||||||
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
|
// ⚠️ Il gate va PRIMA di ogni query sui dati del progetto: se si interroga il
|
||||||
// DB e poi si decide di mostrare il form, i dati sono già nel payload RSC
|
// DB e poi si decide di mostrare il form, i dati sono già nel payload RSC
|
||||||
@@ -161,6 +177,7 @@ export default async function ClientPage({
|
|||||||
adminAvatarUrl: admin.avatarUrl,
|
adminAvatarUrl: admin.avatarUrl,
|
||||||
}}
|
}}
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
initialChatChannel={resolveChatChannel(chatParam, view)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -214,6 +231,7 @@ export default async function ClientPage({
|
|||||||
}}
|
}}
|
||||||
embedded
|
embedded
|
||||||
preview={preview}
|
preview={preview}
|
||||||
|
initialChatChannel={resolveChatChannel(chatParam, view)}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
<p className="text-sm text-muted-foreground">Progetto non disponibile.</p>
|
||||||
|
|||||||
@@ -5,13 +5,13 @@ 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";
|
||||||
import { SearchInput } from "@/components/ui/SearchInput";
|
import { SearchInput } from "@/components/ui/SearchInput";
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Textarea } from "@/components/ui/textarea";
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { MentionText } from "@/components/ui/MentionText";
|
||||||
import { cn } from "@/lib/utils";
|
import { cn } from "@/lib/utils";
|
||||||
|
import { ReplyComposer } from "./ReplyComposer";
|
||||||
import {
|
import {
|
||||||
editConversationMessage,
|
editConversationMessage,
|
||||||
markConversationRead,
|
markConversationRead,
|
||||||
replyToConversation,
|
|
||||||
} from "@/app/admin/conversazioni/actions";
|
} from "@/app/admin/conversazioni/actions";
|
||||||
import type {
|
import type {
|
||||||
ConversationSummary,
|
ConversationSummary,
|
||||||
@@ -93,7 +93,7 @@ export function ConversationsView({
|
|||||||
) : (
|
) : (
|
||||||
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
<div className="flex-1 flex flex-col items-center justify-center text-center gap-3 text-muted-foreground">
|
||||||
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
<MessageSquare className="w-10 h-10 opacity-40" strokeWidth={1.5} />
|
||||||
<p className="text-sm">Nessun messaggio dai clienti ancora.</p>
|
<p className="text-sm">Scegli un cliente per aprire la conversazione.</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</section>
|
</section>
|
||||||
@@ -126,18 +126,28 @@ function ConversationListItem({
|
|||||||
>
|
>
|
||||||
{conv.name}
|
{conv.name}
|
||||||
</span>
|
</span>
|
||||||
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
{conv.lastMessageAt && (
|
||||||
{formatListTime(conv.lastMessageAt)}
|
<span className="text-[10px] font-mono text-muted-foreground shrink-0">
|
||||||
</span>
|
{formatListTime(conv.lastMessageAt)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-1 text-xs text-muted-foreground truncate">
|
<p className="mt-1 text-xs text-muted-foreground truncate">
|
||||||
{conv.lastMessageAuthor === "admin" && (
|
{conv.lastMessageAt ? (
|
||||||
<span className="text-muted-foreground/70">Tu: </span>
|
<>
|
||||||
|
{conv.lastMessageAuthor === "admin" && (
|
||||||
|
<span className="text-muted-foreground/70">Tu: </span>
|
||||||
|
)}
|
||||||
|
{conv.lastMessage}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="italic text-muted-foreground/70">
|
||||||
|
Nessun messaggio — scrivi tu per primo
|
||||||
|
</span>
|
||||||
)}
|
)}
|
||||||
{conv.lastMessage}
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-2 flex items-center justify-between gap-2">
|
<div className="mt-2 flex items-center justify-between gap-2">
|
||||||
<EntityBadge label={conv.lastEntityLabel} />
|
{conv.lastMessageAt ? <EntityBadge label={conv.lastEntityLabel} /> : <span />}
|
||||||
{conv.unread && (
|
{conv.unread && (
|
||||||
<span className="flex items-center gap-1.5 shrink-0">
|
<span className="flex items-center gap-1.5 shrink-0">
|
||||||
{conv.unreadCount > 1 && (
|
{conv.unreadCount > 1 && (
|
||||||
@@ -272,34 +282,29 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
<div ref={scrollRef} className="flex-1 overflow-y-auto p-6 bg-muted/20 space-y-4">
|
||||||
{visibleMessages.length === 0 ? (
|
{visibleMessages.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground text-center py-10">
|
<p className="text-sm text-muted-foreground text-center py-10">
|
||||||
Nessun messaggio in questo canale.
|
Nessun messaggio in questo canale. Scrivi tu per primo.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
visibleMessages.map((m) => (
|
visibleMessages.map((m) => (
|
||||||
<MessageBubble key={m.id} message={m} clientId={thread.clientId} />
|
<MessageBubble
|
||||||
|
key={m.id}
|
||||||
|
message={m}
|
||||||
|
clientId={thread.clientId}
|
||||||
|
mentionCandidates={thread.mentionAliases}
|
||||||
|
/>
|
||||||
))
|
))
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Reply box — il canale viaggia col messaggio */}
|
{/* Reply box — il canale viaggia col messaggio */}
|
||||||
<form
|
<ReplyComposer
|
||||||
action={replyToConversation.bind(null, thread.clientId)}
|
clientId={thread.clientId}
|
||||||
className="p-4 border-t border-border bg-card flex items-end gap-3"
|
channelKey={activeChannel}
|
||||||
>
|
channelLabel={activeLabel}
|
||||||
<input type="hidden" name="entity_type" value={isGeneral ? "general" : "phase"} />
|
isGeneral={isGeneral}
|
||||||
<input type="hidden" name="entity_id" value={activeChannel} />
|
mentionTargets={thread.mentionTargets}
|
||||||
<Textarea
|
notifyEmailCount={thread.notifyEmailCount}
|
||||||
name="body"
|
/>
|
||||||
rows={2}
|
|
||||||
required
|
|
||||||
placeholder={`Rispondi in ${activeLabel}...`}
|
|
||||||
aria-label={`Rispondi nel canale ${activeLabel}`}
|
|
||||||
className="flex-1 resize-none"
|
|
||||||
/>
|
|
||||||
<Button type="submit" size="sm" className="shrink-0">
|
|
||||||
Invia
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -307,9 +312,11 @@ function ActiveThread({ thread }: { thread: ConversationThread }) {
|
|||||||
function MessageBubble({
|
function MessageBubble({
|
||||||
message,
|
message,
|
||||||
clientId,
|
clientId,
|
||||||
|
mentionCandidates,
|
||||||
}: {
|
}: {
|
||||||
message: ConversationThread["messages"][number];
|
message: ConversationThread["messages"][number];
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
mentionCandidates: 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.
|
||||||
@@ -391,7 +398,17 @@ function MessageBubble({
|
|||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-xs leading-relaxed whitespace-pre-wrap">
|
<p className="text-xs leading-relaxed whitespace-pre-wrap">
|
||||||
{message.body}
|
<MentionText
|
||||||
|
body={message.body}
|
||||||
|
candidates={mentionCandidates}
|
||||||
|
// Sulla bolla admin il fondo è `bg-primary`: un chip tenue ci
|
||||||
|
// sparirebbe sopra, quindi si scava invece di colorare.
|
||||||
|
mentionClassName={
|
||||||
|
isAdmin
|
||||||
|
? "rounded bg-primary-foreground/20 px-1 font-semibold"
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,219 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useRef, useState, useTransition } from "react";
|
||||||
|
import { AtSign } from "lucide-react";
|
||||||
|
import { Button } from "@/components/ui/button";
|
||||||
|
import { Textarea } from "@/components/ui/textarea";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { normalizeForSearch, type MentionTarget } from "@/lib/mentions";
|
||||||
|
import { replyToConversation } from "@/app/admin/conversazioni/actions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Casella di risposta dell'inbox, con il tag del cliente.
|
||||||
|
*
|
||||||
|
* È controllata — e non più un `<form action>` con una textarea libera — perché
|
||||||
|
* il suggerimento del tag deve leggere il testo mentre lo scrivi e riscriverlo
|
||||||
|
* al punto giusto. Il canale continua a viaggiare col messaggio: `entity_id` è
|
||||||
|
* il canale aperto, `entity_type` si deriva da esso.
|
||||||
|
*/
|
||||||
|
export function ReplyComposer({
|
||||||
|
clientId,
|
||||||
|
channelKey,
|
||||||
|
channelLabel,
|
||||||
|
isGeneral,
|
||||||
|
mentionTargets,
|
||||||
|
notifyEmailCount,
|
||||||
|
}: {
|
||||||
|
clientId: string;
|
||||||
|
channelKey: string;
|
||||||
|
channelLabel: string;
|
||||||
|
isGeneral: boolean;
|
||||||
|
/**
|
||||||
|
* Chi si può taggare: **una voce per destinatario**, non una per modo di
|
||||||
|
* scriverlo. Prima qui arrivava la lista piatta degli alias e il menu mostrava
|
||||||
|
* tre righe — nome, brand, nome di battesimo — per una persona sola.
|
||||||
|
*/
|
||||||
|
mentionTargets: MentionTarget[];
|
||||||
|
/** Quante mail partirebbero davvero con un tag. Zero = si avvisa, non si tace. */
|
||||||
|
notifyEmailCount: number;
|
||||||
|
}) {
|
||||||
|
const [body, setBody] = useState("");
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [sending, startSending] = useTransition();
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||||
|
|
||||||
|
// Posizione dell'ultima "@" ancora aperta e testo digitato dopo: null quando
|
||||||
|
// non c'è nessun tag in corso di scrittura.
|
||||||
|
const [trigger, setTrigger] = useState<{ at: number; query: string } | null>(null);
|
||||||
|
|
||||||
|
const suggestions = useMemo(() => {
|
||||||
|
if (!trigger) return [];
|
||||||
|
const q = normalizeForSearch(trigger.query);
|
||||||
|
// Si filtra sugli alias ma si tiene il TARGET: digitando «car» il cliente si
|
||||||
|
// trova passando dal brand, e resta comunque una riga sola col suo nome.
|
||||||
|
// La normalizzazione è quella del parser, così «nicolo» trova «Nicolò».
|
||||||
|
return mentionTargets
|
||||||
|
.filter((t) => t.aliases.some((a) => normalizeForSearch(a).startsWith(q)))
|
||||||
|
.slice(0, 6);
|
||||||
|
}, [trigger, mentionTargets]);
|
||||||
|
|
||||||
|
const open = suggestions.length > 0;
|
||||||
|
|
||||||
|
function syncTrigger(value: string, caret: number) {
|
||||||
|
const before = value.slice(0, caret);
|
||||||
|
const at = before.lastIndexOf("@");
|
||||||
|
if (at === -1) return setTrigger(null);
|
||||||
|
// Una "@" vale solo a inizio parola: "email@dominio.it" non apre il menu.
|
||||||
|
const prev = at > 0 ? before[at - 1] : " ";
|
||||||
|
if (!/\s/.test(prev)) return setTrigger(null);
|
||||||
|
const query = before.slice(at + 1);
|
||||||
|
// Un tag non attraversa un a capo, e oltre il nome più lungo non c'è più
|
||||||
|
// niente da suggerire.
|
||||||
|
if (query.includes("\n")) return setTrigger(null);
|
||||||
|
setTrigger({ at, query });
|
||||||
|
}
|
||||||
|
|
||||||
|
function insert(candidate: string) {
|
||||||
|
if (!trigger) return;
|
||||||
|
const el = textareaRef.current;
|
||||||
|
const caret = el?.selectionStart ?? body.length;
|
||||||
|
const next = `${body.slice(0, trigger.at)}@${candidate} ${body.slice(caret)}`;
|
||||||
|
const nextCaret = trigger.at + candidate.length + 2;
|
||||||
|
setBody(next);
|
||||||
|
setTrigger(null);
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(nextCaret, nextCaret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Il pulsante @: inserisce la chiocciola e apre subito il menu. */
|
||||||
|
function startMention() {
|
||||||
|
const el = textareaRef.current;
|
||||||
|
const caret = el?.selectionStart ?? body.length;
|
||||||
|
const needsSpace = caret > 0 && !/\s/.test(body[caret - 1]);
|
||||||
|
const prefix = needsSpace ? " @" : "@";
|
||||||
|
const next = `${body.slice(0, caret)}${prefix}${body.slice(caret)}`;
|
||||||
|
const nextCaret = caret + prefix.length;
|
||||||
|
setBody(next);
|
||||||
|
setTrigger({ at: nextCaret - 1, query: "" });
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
el?.focus();
|
||||||
|
el?.setSelectionRange(nextCaret, nextCaret);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function send() {
|
||||||
|
const text = body.trim();
|
||||||
|
if (!text || sending) return;
|
||||||
|
const fd = new FormData();
|
||||||
|
fd.set("entity_type", isGeneral ? "general" : "phase");
|
||||||
|
fd.set("entity_id", channelKey);
|
||||||
|
fd.set("body", text);
|
||||||
|
setError(null);
|
||||||
|
startSending(async () => {
|
||||||
|
try {
|
||||||
|
await replyToConversation(clientId, fd);
|
||||||
|
setBody("");
|
||||||
|
setTrigger(null);
|
||||||
|
} catch {
|
||||||
|
setError("Messaggio non inviato, riprova");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-border bg-card p-4">
|
||||||
|
<div className="relative flex items-end gap-3">
|
||||||
|
{open && (
|
||||||
|
<ul
|
||||||
|
role="listbox"
|
||||||
|
aria-label="Tagga il cliente"
|
||||||
|
className="absolute bottom-full left-0 z-20 mb-2 w-64 overflow-hidden rounded-lg border border-border bg-card shadow-lg"
|
||||||
|
>
|
||||||
|
{suggestions.map((target, i) => (
|
||||||
|
<li key={target.label}>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
role="option"
|
||||||
|
aria-selected={i === 0}
|
||||||
|
// onMouseDown e non onClick: il click toglierebbe il fuoco alla
|
||||||
|
// textarea prima di leggerne il caret, e l'inserimento finirebbe
|
||||||
|
// nel posto sbagliato.
|
||||||
|
onMouseDown={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
insert(target.label);
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
"flex w-full items-center gap-2 px-3 py-2 text-left text-sm transition-colors hover:bg-muted",
|
||||||
|
i === 0 && "bg-muted/50"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<AtSign className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
||||||
|
<span className="truncate font-medium text-foreground">{target.label}</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={body}
|
||||||
|
onChange={(e) => {
|
||||||
|
setBody(e.target.value);
|
||||||
|
syncTrigger(e.target.value, e.target.selectionStart ?? e.target.value.length);
|
||||||
|
}}
|
||||||
|
onClick={(e) => {
|
||||||
|
const el = e.currentTarget;
|
||||||
|
syncTrigger(el.value, el.selectionStart ?? el.value.length);
|
||||||
|
}}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (open && (e.key === "Enter" || e.key === "Tab")) {
|
||||||
|
e.preventDefault();
|
||||||
|
insert(suggestions[0].label);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (e.key === "Escape" && open) {
|
||||||
|
e.preventDefault();
|
||||||
|
setTrigger(null);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// Invio manda, Shift+Invio va a capo: il comportamento che il
|
||||||
|
// pannello del cliente ha già.
|
||||||
|
if (e.key === "Enter" && !e.shiftKey) {
|
||||||
|
e.preventDefault();
|
||||||
|
send();
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
rows={2}
|
||||||
|
placeholder={`Rispondi in ${channelLabel}...`}
|
||||||
|
aria-label={`Rispondi nel canale ${channelLabel}`}
|
||||||
|
className="flex-1 resize-none"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div className="flex shrink-0 flex-col items-end gap-2">
|
||||||
|
<Button type="button" size="sm" onClick={send} disabled={sending || !body.trim()}>
|
||||||
|
{sending ? "Invio…" : "Invia"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap items-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={startMention}
|
||||||
|
className="inline-flex items-center gap-1 text-[11px] font-semibold text-muted-foreground transition-colors hover:text-foreground"
|
||||||
|
>
|
||||||
|
<AtSign className="h-3 w-3" /> Tagga
|
||||||
|
</button>
|
||||||
|
<span className="text-[11px] text-muted-foreground">
|
||||||
|
{notifyEmailCount > 0
|
||||||
|
? `Un tag gli manda una mail (${notifyEmailCount} ${notifyEmailCount === 1 ? "indirizzo" : "indirizzi"}).`
|
||||||
|
: "Nessuna email a registro: il tag resta solo in chat."}
|
||||||
|
</span>
|
||||||
|
{error && <span className="text-[11px] text-destructive">{error}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -58,7 +58,10 @@ export async function InboxBand() {
|
|||||||
{conv.brand_name}
|
{conv.brand_name}
|
||||||
</h3>
|
</h3>
|
||||||
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
|
<span className="text-[10px] text-muted-foreground font-mono tabular-nums shrink-0">
|
||||||
{relativeTime(conv.lastMessageAt)}
|
{/* `unread` implica almeno un messaggio: il null non capita,
|
||||||
|
ma il tipo lo ammette da quando la lista include anche i
|
||||||
|
clienti con cui non si è ancora scritto. */}
|
||||||
|
{conv.lastMessageAt ? relativeTime(conv.lastMessageAt) : ""}
|
||||||
</span>
|
</span>
|
||||||
{conv.unreadCount > 1 && (
|
{conv.unreadCount > 1 && (
|
||||||
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
|
<span className="text-[10px] font-bold text-emerald-700 dark:text-emerald-400 shrink-0">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { ClientView } from '@/lib/client-view';
|
import type { ClientView } from '@/lib/client-view';
|
||||||
|
import { mentionCandidates } from '@/lib/mentions';
|
||||||
import type { ChatData } from '@/lib/chat-channels';
|
import type { ChatData } from '@/lib/chat-channels';
|
||||||
import { RefreshCw } from 'lucide-react';
|
import { RefreshCw } from 'lucide-react';
|
||||||
import { PhaseTimeline } from './phase-timeline';
|
import { PhaseTimeline } from './phase-timeline';
|
||||||
@@ -23,9 +24,18 @@ interface ClientDashboardProps {
|
|||||||
embedded?: boolean;
|
embedded?: boolean;
|
||||||
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
|
/** Anteprima admin: sola lettura, approvazioni e chat disattivate. */
|
||||||
preview?: boolean;
|
preview?: boolean;
|
||||||
|
/** Canale da aprire all'arrivo (`?chat=`), gia' validato dalla pagina. */
|
||||||
|
initialChatChannel?: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ClientDashboard({ view, token, chat, embedded = false, preview = false }: ClientDashboardProps) {
|
export function ClientDashboard({
|
||||||
|
view,
|
||||||
|
token,
|
||||||
|
chat,
|
||||||
|
embedded = false,
|
||||||
|
preview = false,
|
||||||
|
initialChatChannel = null,
|
||||||
|
}: ClientDashboardProps) {
|
||||||
// Determine payment display mode based on active offers.
|
// Determine payment display mode based on active offers.
|
||||||
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
// Solo i retainer ATTIVI cambiano la modalità: uno sospeso continuerebbe
|
||||||
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
// altrimenti a intestare i pagamenti "Totale Pagamento Mensile" e a
|
||||||
@@ -43,7 +53,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PreviewProvider preview={preview}>
|
<PreviewProvider preview={preview}>
|
||||||
<ChatProvider phases={view.phases} clientId={view.client.id}>
|
<ChatProvider
|
||||||
|
phases={view.phases}
|
||||||
|
clientId={view.client.id}
|
||||||
|
initialChannel={initialChatChannel}
|
||||||
|
>
|
||||||
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
<div className={embedded ? "" : "min-h-screen bg-background"}>
|
||||||
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
{/* Header portale — iamcavalli · Client Portal | brand | area protetta */}
|
||||||
{!embedded && (
|
{!embedded && (
|
||||||
@@ -161,7 +175,11 @@ export function ClientDashboard({ view, token, chat, embedded = false, preview =
|
|||||||
</footer>
|
</footer>
|
||||||
|
|
||||||
{/* Floating chat panel — FAB + slide-in panel */}
|
{/* Floating chat panel — FAB + slide-in panel */}
|
||||||
<ChatPanel token={token} chat={chat} />
|
<ChatPanel
|
||||||
|
token={token}
|
||||||
|
chat={chat}
|
||||||
|
mentionCandidates={mentionCandidates(view.client)}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</ChatProvider>
|
</ChatProvider>
|
||||||
</PreviewProvider>
|
</PreviewProvider>
|
||||||
|
|||||||
@@ -10,12 +10,13 @@ import {
|
|||||||
type ChatData,
|
type ChatData,
|
||||||
type ChatMessage,
|
type ChatMessage,
|
||||||
} from "@/lib/chat-channels";
|
} from "@/lib/chat-channels";
|
||||||
|
import { MentionText } from "@/components/ui/MentionText";
|
||||||
import { useChatContext } from "./ChatProvider";
|
import { useChatContext } from "./ChatProvider";
|
||||||
import { usePreview } from "./PreviewProvider";
|
import { usePreview } from "./PreviewProvider";
|
||||||
|
|
||||||
// TODO: la mail di notifica al cliente ("hai un messaggio non letto") è un giro a
|
// La notifica via mail al cliente esiste, ma solo per i messaggi in cui l'admin
|
||||||
// sé: serve uno scheduler, che nel repo non esiste ancora. Hook point naturale:
|
// lo tagga (@Nome): parte da replyToConversation, senza scheduler. Una notifica
|
||||||
// una query su client_channel_reads incrociata con comments.author = 'admin'.
|
// per OGNI messaggio non letto sarebbe un'altra cosa e richiederebbe un job.
|
||||||
|
|
||||||
const POLL_MS = 20_000;
|
const POLL_MS = 20_000;
|
||||||
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
|
/** Oltre questo scarto due messaggi dello stesso autore non si raggruppano più. */
|
||||||
@@ -60,7 +61,16 @@ function monogram(label: string): string {
|
|||||||
return (parts[0][0] + parts[1][0]).toUpperCase();
|
return (parts[0][0] + parts[1][0]).toUpperCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
export function ChatPanel({
|
||||||
|
token,
|
||||||
|
chat,
|
||||||
|
mentionCandidates,
|
||||||
|
}: {
|
||||||
|
token: string;
|
||||||
|
chat: ChatData;
|
||||||
|
/** I nomi con cui l'admin può taggare questo cliente — vedi lib/mentions.ts. */
|
||||||
|
mentionCandidates: string[];
|
||||||
|
}) {
|
||||||
const {
|
const {
|
||||||
isOpen,
|
isOpen,
|
||||||
expanded,
|
expanded,
|
||||||
@@ -495,6 +505,7 @@ export function ChatPanel({ token, chat }: { token: string; chat: ChatData }) {
|
|||||||
clientId={clientId}
|
clientId={clientId}
|
||||||
adminName={adminName}
|
adminName={adminName}
|
||||||
adminAvatarUrl={adminAvatarUrl}
|
adminAvatarUrl={adminAvatarUrl}
|
||||||
|
mentionCandidates={mentionCandidates}
|
||||||
canEdit={!preview}
|
canEdit={!preview}
|
||||||
onEdit={applyEdit}
|
onEdit={applyEdit}
|
||||||
/>
|
/>
|
||||||
@@ -605,6 +616,7 @@ function MessageList({
|
|||||||
clientId,
|
clientId,
|
||||||
adminName,
|
adminName,
|
||||||
adminAvatarUrl,
|
adminAvatarUrl,
|
||||||
|
mentionCandidates,
|
||||||
canEdit,
|
canEdit,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
@@ -614,6 +626,7 @@ function MessageList({
|
|||||||
clientId: string;
|
clientId: string;
|
||||||
adminName: string;
|
adminName: string;
|
||||||
adminAvatarUrl: string | null;
|
adminAvatarUrl: string | null;
|
||||||
|
mentionCandidates: string[];
|
||||||
/** Falso in anteprima admin: si guarda, non si scrive. */
|
/** Falso in anteprima admin: si guarda, non si scrive. */
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||||
@@ -647,6 +660,7 @@ function MessageList({
|
|||||||
grouped={grouped}
|
grouped={grouped}
|
||||||
adminName={adminName}
|
adminName={adminName}
|
||||||
adminAvatarUrl={adminAvatarUrl}
|
adminAvatarUrl={adminAvatarUrl}
|
||||||
|
mentionCandidates={mentionCandidates}
|
||||||
// Dentro un canale-fase, i messaggi storici su un task o un deliverable
|
// 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.
|
// restano riconoscibili: il canale li raccoglie, il badge dice su cosa erano.
|
||||||
entityLabel={
|
entityLabel={
|
||||||
@@ -676,6 +690,7 @@ function MessageRow({
|
|||||||
entityLabel,
|
entityLabel,
|
||||||
adminName,
|
adminName,
|
||||||
adminAvatarUrl,
|
adminAvatarUrl,
|
||||||
|
mentionCandidates,
|
||||||
canEdit,
|
canEdit,
|
||||||
onEdit,
|
onEdit,
|
||||||
}: {
|
}: {
|
||||||
@@ -686,6 +701,7 @@ function MessageRow({
|
|||||||
entityLabel?: string;
|
entityLabel?: string;
|
||||||
adminName: string;
|
adminName: string;
|
||||||
adminAvatarUrl: string | null;
|
adminAvatarUrl: string | null;
|
||||||
|
mentionCandidates: string[];
|
||||||
canEdit: boolean;
|
canEdit: boolean;
|
||||||
onEdit: (id: string, body: string) => Promise<boolean>;
|
onEdit: (id: string, body: string) => Promise<boolean>;
|
||||||
}) {
|
}) {
|
||||||
@@ -803,7 +819,7 @@ function MessageRow({
|
|||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
<p className="mt-1 whitespace-pre-wrap text-sm leading-relaxed text-foreground">
|
||||||
{m.body}
|
<MentionText body={m.body} candidates={mentionCandidates} />
|
||||||
{m.edited_at && (
|
{m.edited_at && (
|
||||||
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
<span className="ml-1.5 text-[10px] text-muted-foreground">
|
||||||
(modificato)
|
(modificato)
|
||||||
|
|||||||
@@ -36,16 +36,31 @@ export function ChatProvider({
|
|||||||
children,
|
children,
|
||||||
phases,
|
phases,
|
||||||
clientId,
|
clientId,
|
||||||
|
initialChannel,
|
||||||
}: {
|
}: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
phases: ClientView["phases"];
|
phases: ClientView["phases"];
|
||||||
clientId: string;
|
clientId: string;
|
||||||
|
/**
|
||||||
|
* Canale richiesto da fuori con `?chat=<key>` — è così che il pulsante della
|
||||||
|
* mail di tag porta il cliente dentro la conversazione giusta invece che sulla
|
||||||
|
* dashboard, dove la chat resterebbe chiusa e il messaggio non letto.
|
||||||
|
*
|
||||||
|
* Arriva dal server, già validato: leggerlo qui da `window.location` vorrebbe
|
||||||
|
* dire aprire il pannello in un effetto, cioè renderizzarlo chiuso e riaprirlo
|
||||||
|
* subito dopo l'idratazione.
|
||||||
|
*/
|
||||||
|
initialChannel?: string | null;
|
||||||
}) {
|
}) {
|
||||||
const [isOpen, setIsOpen] = useState(false);
|
// Un canale richiesto vale anche come "apri il pannello": chi arriva da quel
|
||||||
|
// link sta venendo a leggere un messaggio, non a guardare la timeline.
|
||||||
|
const [isOpen, setIsOpen] = useState(!!initialChannel);
|
||||||
const [expanded, setExpanded] = useState(false);
|
const [expanded, setExpanded] = useState(false);
|
||||||
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
|
// Il canale "Generale" è identificato dall'id del cliente: stessa convenzione
|
||||||
// di comments.entity_id, così non serve tradurre nulla in scrittura.
|
// di comments.entity_id, così non serve tradurre nulla in scrittura.
|
||||||
const [activeChannel, setActiveChannel] = useState<string>(clientId);
|
const [activeChannel, setActiveChannel] = useState<string>(
|
||||||
|
initialChannel ?? clientId
|
||||||
|
);
|
||||||
|
|
||||||
const openChat = useCallback(
|
const openChat = useCallback(
|
||||||
(phaseId?: string) => {
|
(phaseId?: string) => {
|
||||||
|
|||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { Fragment } from "react";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
import { splitMentions } from "@/lib/mentions";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Corpo di un messaggio di chat con le menzioni evidenziate.
|
||||||
|
*
|
||||||
|
* Sostituisce `{m.body}` dentro il paragrafo: mantiene `whitespace-pre-wrap` sul
|
||||||
|
* genitore, quindi gli a capo continuano a funzionare senza dover spezzare il
|
||||||
|
* testo in righe qui dentro.
|
||||||
|
*/
|
||||||
|
export function MentionText({
|
||||||
|
body,
|
||||||
|
candidates,
|
||||||
|
mentionClassName,
|
||||||
|
}: {
|
||||||
|
body: string;
|
||||||
|
candidates: string[];
|
||||||
|
/** Il contrasto cambia col fondo della bolla: lo decide chi la disegna. */
|
||||||
|
mentionClassName?: string;
|
||||||
|
}) {
|
||||||
|
const parts = splitMentions(body, candidates);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{parts.map((part, i) =>
|
||||||
|
part.type === "mention" ? (
|
||||||
|
<span
|
||||||
|
key={i}
|
||||||
|
className={cn(
|
||||||
|
"rounded px-1 font-semibold",
|
||||||
|
mentionClassName ??
|
||||||
|
"bg-primary/10 text-primary dark:bg-primary/20"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{part.value}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<Fragment key={i}>{part.value}</Fragment>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,160 @@
|
|||||||
|
import { eq } from "drizzle-orm";
|
||||||
|
import { db } from "@/db";
|
||||||
|
import { clients, client_emails, phases } from "@/db/schema";
|
||||||
|
import { mentionEmailTemplate, sendEmail } from "@/lib/mailer";
|
||||||
|
import { getAdminIdentity } from "@/lib/settings";
|
||||||
|
import { GENERAL_LABEL } from "@/lib/chat-channels";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifica al cliente che è stato taggato in chat.
|
||||||
|
*
|
||||||
|
* Il portale non manda una notifica per ogni messaggio, e non deve: il cliente
|
||||||
|
* lo apre quando gli pare. Il tag è l'eccezione dichiarata — l'admin decide che
|
||||||
|
* quel messaggio non può aspettare il prossimo accesso, e quella decisione vale
|
||||||
|
* una mail. Nessuno scheduler, nessuna coda: l'invio parte dalla stessa azione
|
||||||
|
* che scrive il messaggio.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Gli indirizzi che ricevono la notifica: la whitelist OTP (le persone che
|
||||||
|
* possono davvero entrare nel portale) più l'indirizzo di contatto sulla scheda.
|
||||||
|
* Deduplicati senza distinzione di maiuscole, perché lo stesso indirizzo scritto
|
||||||
|
* in due modi è una persona sola.
|
||||||
|
*/
|
||||||
|
export async function getMentionRecipients(clientId: string): Promise<string[]> {
|
||||||
|
const [whitelisted, clientRows] = await Promise.all([
|
||||||
|
db
|
||||||
|
.select({ email: client_emails.email })
|
||||||
|
.from(client_emails)
|
||||||
|
.where(eq(client_emails.client_id, clientId)),
|
||||||
|
db
|
||||||
|
.select({ email: clients.email })
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of [
|
||||||
|
...whitelisted.map((r) => r.email),
|
||||||
|
clientRows[0]?.email ?? null,
|
||||||
|
]) {
|
||||||
|
const email = value?.trim();
|
||||||
|
if (!email) continue;
|
||||||
|
const key = email.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(email);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Base pubblica del portale. `NEXTAUTH_URL` è già l'origine giusta in ogni
|
||||||
|
* ambiente; `APP_BASE_URL` esiste solo per il caso in cui le due debbano
|
||||||
|
* divergere. Se manca tutto si torna null: meglio una mail senza link che una
|
||||||
|
* mail con un link a localhost.
|
||||||
|
*/
|
||||||
|
function portalBaseUrl(): string | null {
|
||||||
|
const raw = process.env.APP_BASE_URL ?? process.env.NEXTAUTH_URL;
|
||||||
|
const trimmed = raw?.trim().replace(/\/+$/, "");
|
||||||
|
return trimmed || null;
|
||||||
|
}
|
||||||
|
|
||||||
|
type MentionInput = {
|
||||||
|
clientId: string;
|
||||||
|
/** clients.id per "Generale", phases.id per una fase. */
|
||||||
|
channelKey: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type MentionNotifyResult = {
|
||||||
|
/** Quante mail sono partite davvero. */
|
||||||
|
sent: number;
|
||||||
|
recipients: number;
|
||||||
|
errors: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non lancia mai: il messaggio in chat è già stato scritto e resta valido anche
|
||||||
|
* se Resend è giù. Un errore d'invio si logga e si restituisce, non si propaga
|
||||||
|
* fino a far sembrare fallita la risposta al cliente.
|
||||||
|
*/
|
||||||
|
export async function notifyClientMention({
|
||||||
|
clientId,
|
||||||
|
channelKey,
|
||||||
|
body,
|
||||||
|
}: MentionInput): Promise<MentionNotifyResult> {
|
||||||
|
const result: MentionNotifyResult = { sent: 0, recipients: 0, errors: [] };
|
||||||
|
|
||||||
|
try {
|
||||||
|
const [clientRow] = await db
|
||||||
|
.select({
|
||||||
|
name: clients.name,
|
||||||
|
brand_name: clients.brand_name,
|
||||||
|
token: clients.token,
|
||||||
|
slug: clients.slug,
|
||||||
|
})
|
||||||
|
.from(clients)
|
||||||
|
.where(eq(clients.id, clientId))
|
||||||
|
.limit(1);
|
||||||
|
if (!clientRow) return result;
|
||||||
|
|
||||||
|
const recipients = await getMentionRecipients(clientId);
|
||||||
|
result.recipients = recipients.length;
|
||||||
|
if (recipients.length === 0) return result;
|
||||||
|
|
||||||
|
const [admin, channelLabel] = await Promise.all([
|
||||||
|
getAdminIdentity(),
|
||||||
|
resolveChannelLabel(clientId, channelKey),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const base = portalBaseUrl();
|
||||||
|
// Lo slug è il link "bello" quando c'è; il token è sempre valido. Entrambi
|
||||||
|
// sono credenziali al portatore: finiscono solo nella mail del cliente.
|
||||||
|
const url = base
|
||||||
|
? `${base}/client/${clientRow.slug ?? clientRow.token}?chat=${encodeURIComponent(channelKey)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const { subject, html } = mentionEmailTemplate({
|
||||||
|
clientName: clientRow.name,
|
||||||
|
adminName: admin.name,
|
||||||
|
channelLabel,
|
||||||
|
body,
|
||||||
|
url,
|
||||||
|
});
|
||||||
|
|
||||||
|
// In serie e non in parallelo: sono uno o due indirizzi, e un 429 di Resend
|
||||||
|
// su un burst costerebbe più di quanto valga la concorrenza.
|
||||||
|
for (const to of recipients) {
|
||||||
|
const outcome = await sendEmail({ to, subject, html });
|
||||||
|
if (outcome.ok) result.sent++;
|
||||||
|
else result.errors.push(`${to}: ${outcome.error}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errors.length > 0) {
|
||||||
|
console.error("[notifyClientMention] invii falliti:", result.errors.join(" | "));
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "errore sconosciuto";
|
||||||
|
result.errors.push(message);
|
||||||
|
console.error("[notifyClientMention] errore:", message);
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** "Generale" o il titolo della fase: è la stessa etichetta che il cliente vede sul tab. */
|
||||||
|
async function resolveChannelLabel(
|
||||||
|
clientId: string,
|
||||||
|
channelKey: string
|
||||||
|
): Promise<string> {
|
||||||
|
if (channelKey === clientId) return GENERAL_LABEL;
|
||||||
|
const [row] = await db
|
||||||
|
.select({ title: phases.title })
|
||||||
|
.from(phases)
|
||||||
|
.where(eq(phases.id, channelKey))
|
||||||
|
.limit(1);
|
||||||
|
return row?.title?.trim() || GENERAL_LABEL;
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import { clients, projects, phases, tasks, deliverables, comments } from "@/db/s
|
|||||||
import { eq, inArray, asc } from "drizzle-orm";
|
import { eq, inArray, asc } from "drizzle-orm";
|
||||||
import type { Comment } from "@/db/schema";
|
import type { Comment } from "@/db/schema";
|
||||||
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
|
import { buildChannels, type ChatChannel } from "@/lib/chat-channels";
|
||||||
|
import { mentionTargets, mentionAliases, type MentionTarget } from "@/lib/mentions";
|
||||||
|
import { getMentionRecipients } from "@/lib/client-notifications";
|
||||||
|
|
||||||
// ── Types ─────────────────────────────────────────────────────────────────────
|
// ── Types ─────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
@@ -13,9 +15,11 @@ export type ConversationSummary = {
|
|||||||
brand_name: string;
|
brand_name: string;
|
||||||
token: string;
|
token: string;
|
||||||
slug: string | null;
|
slug: string | null;
|
||||||
|
/** Stringa vuota per un cliente con cui non si e' ancora scambiato nulla. */
|
||||||
lastMessage: string;
|
lastMessage: string;
|
||||||
lastMessageAuthor: "client" | "admin";
|
lastMessageAuthor: "client" | "admin" | null;
|
||||||
lastMessageAt: Date;
|
/** null = nessun messaggio: la conversazione esiste ma e' ancora da aprire. */
|
||||||
|
lastMessageAt: Date | null;
|
||||||
lastEntityLabel: string;
|
lastEntityLabel: string;
|
||||||
unread: boolean;
|
unread: boolean;
|
||||||
unreadCount: number;
|
unreadCount: number;
|
||||||
@@ -54,6 +58,23 @@ export type ConversationThread = {
|
|||||||
* refresh il dato non sarebbe più ricostruibile.
|
* refresh il dato non sarebbe più ricostruibile.
|
||||||
*/
|
*/
|
||||||
adminLastReadAt: Date | null;
|
adminLastReadAt: Date | null;
|
||||||
|
/**
|
||||||
|
* **Chi** si può taggare: una voce per destinatario, per il menu del
|
||||||
|
* compositore. Oggi ne esce sempre una — la chat è 1:1.
|
||||||
|
*/
|
||||||
|
mentionTargets: MentionTarget[];
|
||||||
|
/**
|
||||||
|
* **Come** un tag viene riconosciuto rileggendo il testo: tutti gli alias di
|
||||||
|
* tutti i destinatari. Serve solo a evidenziare, mai a offrire una scelta —
|
||||||
|
* confondere le due liste è ciò che faceva comparire tre voci per una persona.
|
||||||
|
*/
|
||||||
|
mentionAliases: string[];
|
||||||
|
/**
|
||||||
|
* Quanti indirizzi riceverebbero la notifica di un tag. Zero significa che il
|
||||||
|
* tag resta un'evidenziazione e basta: il compositore lo dice prima, invece di
|
||||||
|
* lasciar credere che sia partita una mail che non partirà.
|
||||||
|
*/
|
||||||
|
notifyEmailCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ClientMeta = {
|
type ClientMeta = {
|
||||||
@@ -221,17 +242,26 @@ async function fetchComments(allEntityIds: string[]): Promise<Comment[]> {
|
|||||||
|
|
||||||
// ── Public API ──────────────────────────────────────────────────────────────────
|
// ── Public API ──────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
/** One entry per non-archived client that has at least one comment, newest first. */
|
/**
|
||||||
|
* Una voce per OGNI cliente non archiviato, anche senza un solo messaggio.
|
||||||
|
*
|
||||||
|
* Prima l'elenco nasceva dai commenti, quindi un cliente compariva solo dopo
|
||||||
|
* aver scritto per primo: dall'inbox era letteralmente impossibile aprire una
|
||||||
|
* conversazione. Il centro messaggi è la rubrica dei clienti, non l'archivio di
|
||||||
|
* chi ha già parlato — la lista parte da `clients` e i commenti la arricchiscono.
|
||||||
|
*
|
||||||
|
* Ordine: prima chi ha scritto, dal più recente; in coda chi non ha ancora
|
||||||
|
* niente, in ordine alfabetico. Così l'inbox resta un inbox e i clienti muti
|
||||||
|
* non spingono giù le conversazioni vive.
|
||||||
|
*/
|
||||||
export async function getConversations(): Promise<ConversationSummary[]> {
|
export async function getConversations(): Promise<ConversationSummary[]> {
|
||||||
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
const { clientMeta, entityMap, allEntityIds } = await buildEntityMap();
|
||||||
const commentRows = await fetchComments(allEntityIds);
|
const commentRows = await fetchComments(allEntityIds);
|
||||||
const byClient = groupCommentsByClient(commentRows, entityMap);
|
const byClient = groupCommentsByClient(commentRows, entityMap);
|
||||||
|
|
||||||
const summaries: ConversationSummary[] = [];
|
const summaries: ConversationSummary[] = [];
|
||||||
for (const [clientId, msgs] of byClient) {
|
for (const meta of clientMeta.values()) {
|
||||||
const meta = clientMeta.get(clientId);
|
const msgs = byClient.get(meta.id) ?? [];
|
||||||
if (!meta || msgs.length === 0) continue;
|
|
||||||
|
|
||||||
const last = msgs[msgs.length - 1];
|
const last = msgs[msgs.length - 1];
|
||||||
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
|
const lastReadAt = meta.admin_last_read_at?.getTime() ?? 0;
|
||||||
const clientMsgsAfterRead = msgs.filter(
|
const clientMsgsAfterRead = msgs.filter(
|
||||||
@@ -239,21 +269,28 @@ export async function getConversations(): Promise<ConversationSummary[]> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
summaries.push({
|
summaries.push({
|
||||||
clientId,
|
clientId: meta.id,
|
||||||
name: meta.name,
|
name: meta.name,
|
||||||
brand_name: meta.brand_name,
|
brand_name: meta.brand_name,
|
||||||
token: meta.token,
|
token: meta.token,
|
||||||
slug: meta.slug,
|
slug: meta.slug,
|
||||||
lastMessage: last.body,
|
lastMessage: last?.body ?? "",
|
||||||
lastMessageAuthor: last.author as "client" | "admin",
|
lastMessageAuthor: (last?.author as "client" | "admin") ?? null,
|
||||||
lastMessageAt: last.created_at,
|
lastMessageAt: last?.created_at ?? null,
|
||||||
lastEntityLabel: entityMap.get(last.entity_id)?.label ?? "Generale",
|
lastEntityLabel: last ? entityMap.get(last.entity_id)?.label ?? "Generale" : "Generale",
|
||||||
unread: clientMsgsAfterRead.length > 0,
|
unread: clientMsgsAfterRead.length > 0,
|
||||||
unreadCount: clientMsgsAfterRead.length,
|
unreadCount: clientMsgsAfterRead.length,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
summaries.sort((a, b) => b.lastMessageAt.getTime() - a.lastMessageAt.getTime());
|
summaries.sort((a, b) => {
|
||||||
|
if (a.lastMessageAt && b.lastMessageAt) {
|
||||||
|
return b.lastMessageAt.getTime() - a.lastMessageAt.getTime();
|
||||||
|
}
|
||||||
|
if (a.lastMessageAt) return -1;
|
||||||
|
if (b.lastMessageAt) return 1;
|
||||||
|
return a.name.localeCompare(b.name, "it");
|
||||||
|
});
|
||||||
return summaries;
|
return summaries;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -271,6 +308,10 @@ export async function getConversationThread(
|
|||||||
);
|
);
|
||||||
const commentRows = await fetchComments(ownEntityIds);
|
const commentRows = await fetchComments(ownEntityIds);
|
||||||
|
|
||||||
|
// Calcolati una volta: gli alias derivano dai target, e ricavarli due volte
|
||||||
|
// aprirebbe la porta a un menu e a un'evidenziazione che non concordano.
|
||||||
|
const targets = mentionTargets(meta);
|
||||||
|
|
||||||
const messages: ThreadMessage[] = commentRows.map((c) => {
|
const messages: ThreadMessage[] = commentRows.map((c) => {
|
||||||
const info = entityMap.get(c.entity_id);
|
const info = entityMap.get(c.entity_id);
|
||||||
return {
|
return {
|
||||||
@@ -296,6 +337,9 @@ export async function getConversationThread(
|
|||||||
messages,
|
messages,
|
||||||
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
channels: buildChannels(meta.id, phasesByClient.get(clientId) ?? []),
|
||||||
adminLastReadAt: meta.admin_last_read_at,
|
adminLastReadAt: meta.admin_last_read_at,
|
||||||
|
mentionTargets: targets,
|
||||||
|
mentionAliases: mentionAliases(targets),
|
||||||
|
notifyEmailCount: (await getMentionRecipients(clientId)).length,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -73,6 +73,67 @@ export function otpEmailTemplate(code: string, brandName: string): { subject: st
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Notifica «ti hanno taggato in chat».
|
||||||
|
*
|
||||||
|
* Il cliente apre il portale quando gli pare: senza questa mail un messaggio
|
||||||
|
* urgente resta lì finché non gli viene in mente di guardare. Il testo dice una
|
||||||
|
* cosa sola — c'è un messaggio per te, qui — e ci porta con un pulsante.
|
||||||
|
*
|
||||||
|
* L'estratto è volutamente corto: serve a far capire di cosa si parla, non a
|
||||||
|
* sostituire la chat. La conversazione si legge nel portale, dove si risponde.
|
||||||
|
*/
|
||||||
|
export function mentionEmailTemplate({
|
||||||
|
clientName,
|
||||||
|
adminName,
|
||||||
|
channelLabel,
|
||||||
|
body,
|
||||||
|
url,
|
||||||
|
}: {
|
||||||
|
clientName: string;
|
||||||
|
adminName: string;
|
||||||
|
channelLabel: string;
|
||||||
|
body: string;
|
||||||
|
/** null se la base pubblica non è configurata: la mail parte comunque, senza pulsante. */
|
||||||
|
url: string | null;
|
||||||
|
}): { subject: string; html: string } {
|
||||||
|
const firstName = clientName.trim().split(/\s+/)[0] || clientName;
|
||||||
|
const excerpt = truncate(body, 280);
|
||||||
|
|
||||||
|
const button = url
|
||||||
|
? `<p style="margin:0 0 24px;"><a href="${escapeHtml(url)}" style="display:inline-block;background:#1a1a1a;color:#ffffff;text-decoration:none;font-size:15px;font-weight:600;padding:14px 28px;border-radius:8px;">Apri la conversazione</a></p>`
|
||||||
|
: "";
|
||||||
|
|
||||||
|
return {
|
||||||
|
subject: `${adminName} ti ha taggato in chat`,
|
||||||
|
html: `<!doctype html>
|
||||||
|
<html lang="it">
|
||||||
|
<body style="margin:0;padding:32px 16px;background:#f6f6f4;font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Helvetica,Arial,sans-serif;color:#1a1a1a;">
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="max-width:480px;margin:0 auto;background:#ffffff;border-radius:12px;padding:32px;">
|
||||||
|
<tr><td>
|
||||||
|
<p style="margin:0 0 8px;font-size:14px;color:#71717a;">${escapeHtml(channelLabel)}</p>
|
||||||
|
<h1 style="margin:0 0 24px;font-size:20px;font-weight:600;">${escapeHtml(firstName)}, ${escapeHtml(adminName)} ti ha taggato in chat</h1>
|
||||||
|
<table role="presentation" cellpadding="0" cellspacing="0" style="width:100%;margin:0 0 24px;background:#f6f6f4;border-radius:8px;">
|
||||||
|
<tr><td style="padding:16px 18px;font-size:15px;line-height:1.6;color:#1a1a1a;">${escapeHtml(excerpt).replace(/\n/g, "<br>")}</td></tr>
|
||||||
|
</table>
|
||||||
|
${button}
|
||||||
|
<p style="margin:0;font-size:14px;color:#71717a;line-height:1.6;">Rispondi dal portale: la conversazione resta lì, insieme al progetto.</p>
|
||||||
|
</td></tr>
|
||||||
|
</table>
|
||||||
|
</body>
|
||||||
|
</html>`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Taglia sull'ultimo spazio prima del limite, per non spezzare una parola a metà. */
|
||||||
|
function truncate(text: string, max: number): string {
|
||||||
|
const clean = text.trim();
|
||||||
|
if (clean.length <= max) return clean;
|
||||||
|
const cut = clean.slice(0, max);
|
||||||
|
const lastSpace = cut.lastIndexOf(" ");
|
||||||
|
return `${(lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd()}…`;
|
||||||
|
}
|
||||||
|
|
||||||
/** Il brand name arriva dal DB ed è admin-controlled, ma finisce in HTML: si sanifica comunque. */
|
/** Il brand name arriva dal DB ed è admin-controlled, ma finisce in HTML: si sanifica comunque. */
|
||||||
function escapeHtml(s: string): string {
|
function escapeHtml(s: string): string {
|
||||||
return s
|
return s
|
||||||
|
|||||||
@@ -0,0 +1,206 @@
|
|||||||
|
/**
|
||||||
|
* Menzioni nella chat — «@Nome» dentro il corpo di un messaggio.
|
||||||
|
*
|
||||||
|
* Il modello è deliberatamente senza schema: nessuna tabella `mentions`, nessun
|
||||||
|
* marcatore nel testo. Il body resta esattamente quello che l'admin ha scritto,
|
||||||
|
* e la menzione si riconosce a posteriori confrontando il testo con i nomi noti
|
||||||
|
* del cliente di quella conversazione.
|
||||||
|
*
|
||||||
|
* Perché così e non con un token strutturato tipo `@[Nome](client:id)`:
|
||||||
|
* 1. il testo resta leggibile ovunque finisca — email di notifica, anteprima
|
||||||
|
* nella lista, log — senza bisogno di un renderer che lo traduca;
|
||||||
|
* 2. sopravvive alla modifica di un messaggio, che qui riscrive il body e non
|
||||||
|
* saprebbe rigenerare un id nascosto;
|
||||||
|
* 3. non introduce una migration per una funzione che riguarda una sola entità
|
||||||
|
* (la chat è 1:1, admin ↔ cliente: non esiste "chi" da disambiguare).
|
||||||
|
*
|
||||||
|
* Il prezzo: se il cliente cambia nome, le vecchie menzioni smettono di essere
|
||||||
|
* evidenziate. È un difetto cosmetico e si auto-ripara scrivendo il nome nuovo.
|
||||||
|
*
|
||||||
|
* Funzioni pure: nessun accesso al DB, usabili sia sul server sia nel browser.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/** Diacritici Unicode combinanti — via da qui, così «Nicolò» matcha «nicolo». */
|
||||||
|
const COMBINING = /[\u0300-\u036f]/g;
|
||||||
|
/**
|
||||||
|
* Una menzione è delimitata da parola su entrambi i lati:
|
||||||
|
* - a destra, «@Mario» non deve matchare dentro «@Mariotti»;
|
||||||
|
* - a sinistra, la chiocciola deve aprire una parola. Senza questo controllo
|
||||||
|
* «scrivimi a mario@teckell.it» conterrebbe un tag «@Teckell» e farebbe
|
||||||
|
* partire una mail di notifica che nessuno ha chiesto.
|
||||||
|
*/
|
||||||
|
const WORD_CHAR = /[\p{L}\p{N}_]/u;
|
||||||
|
|
||||||
|
function foldChar(ch: string): string {
|
||||||
|
const folded = ch.normalize("NFD").replace(COMBINING, "").toLowerCase();
|
||||||
|
// Un carattere può decomporsi in più code point: si tiene il primo, così la
|
||||||
|
// stringa ripiegata resta allineata all'originale posizione per posizione —
|
||||||
|
// che è quello che permette di ritagliare il testo originale dagli indici.
|
||||||
|
return folded.length > 0 ? folded[0] : ch.toLowerCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* La stessa normalizzazione che usa il parser, esposta per chi deve confrontare
|
||||||
|
* testo digitato con un nome — oggi il filtro del menu nel compositore.
|
||||||
|
*
|
||||||
|
* Va condivisa, non riscritta: con un `toLowerCase()` fatto in casa, digitare
|
||||||
|
* «@nicolo» non troverebbe «Nicolò» nel menu mentre il parser lo riconoscerebbe
|
||||||
|
* benissimo nel messaggio. Due regole diverse per la stessa domanda, e la
|
||||||
|
* seconda si scopre solo quando la mail non parte.
|
||||||
|
*/
|
||||||
|
export function normalizeForSearch(value: string): string {
|
||||||
|
return Array.from(value).map(foldChar).join("");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chi si può taggare, e in quanti modi lo si può scrivere.
|
||||||
|
*
|
||||||
|
* La distinzione è il punto di tutto il modulo, ed è quella che il menu del
|
||||||
|
* compositore aveva sbagliato mostrando tre voci per una persona sola:
|
||||||
|
*
|
||||||
|
* - `label` = **chi**. Una riga per destinatario, ed è il testo che finisce nel
|
||||||
|
* messaggio scegliendolo dal menu.
|
||||||
|
* - `aliases` = **come**. Tutti i modi in cui quel destinatario viene
|
||||||
|
* riconosciuto rileggendo il testo, perché «@Gian» scritto a mano deve valere
|
||||||
|
* quanto «@Gian Luca Caruso» scelto dall'elenco.
|
||||||
|
*
|
||||||
|
* Tenendole separate, il giorno in cui i contatti per cliente saranno tre il
|
||||||
|
* menu avrà tre righe e non nove.
|
||||||
|
*/
|
||||||
|
export type MentionTarget = {
|
||||||
|
/** Il nome che si scrive scegliendolo dal menu. Uno per destinatario. */
|
||||||
|
label: string;
|
||||||
|
/** Ogni forma accettata in lettura, dalla più lunga alla più corta. */
|
||||||
|
aliases: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* I destinatari taggabili in questa conversazione.
|
||||||
|
*
|
||||||
|
* Oggi ne esce sempre uno: la chat è 1:1 e `clients` conosce una persona sola.
|
||||||
|
* La forma è già al plurale perché il giorno in cui i contatti diventano righe
|
||||||
|
* a sé (oggi `client_emails` ha gli indirizzi ma non i nomi) qui cambia solo la
|
||||||
|
* query che li carica, non il menu né il parser.
|
||||||
|
*/
|
||||||
|
export function mentionTargets(client: {
|
||||||
|
name: string;
|
||||||
|
brand_name?: string | null;
|
||||||
|
}): MentionTarget[] {
|
||||||
|
const label = client.name.trim();
|
||||||
|
if (!label) return [];
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
label,
|
||||||
|
// Il brand è un alias della persona, non un secondo destinatario: taggare
|
||||||
|
// «@Caruso Speaker» scrive comunque a chi legge quella conversazione.
|
||||||
|
aliases: dedupe([label, client.brand_name ?? "", label.split(/\s+/)[0] ?? ""]),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tutti gli alias di tutti i destinatari, dal più lungo al più corto: è la lista
|
||||||
|
* che serve al parser. L'ordine conta — «@Mario Rossi» deve vincere su «@Mario»,
|
||||||
|
* altrimenti il chip si fermerebbe a metà del nome.
|
||||||
|
*/
|
||||||
|
export function mentionAliases(targets: ReadonlyArray<MentionTarget>): string[] {
|
||||||
|
return dedupe(targets.flatMap((t) => t.aliases)).sort((a, b) => b.length - a.length);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Scorciatoia per chi deve solo rileggere del testo, senza offrire un menu. */
|
||||||
|
export function mentionCandidates(client: {
|
||||||
|
name: string;
|
||||||
|
brand_name?: string | null;
|
||||||
|
}): string[] {
|
||||||
|
return mentionAliases(mentionTargets(client));
|
||||||
|
}
|
||||||
|
|
||||||
|
function dedupe(values: ReadonlyArray<string>): string[] {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const out: string[] = [];
|
||||||
|
for (const value of values) {
|
||||||
|
const trimmed = value.trim();
|
||||||
|
if (!trimmed) continue;
|
||||||
|
const key = trimmed.toLowerCase();
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
out.push(trimmed);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type MentionPart =
|
||||||
|
| { type: "text"; value: string }
|
||||||
|
| { type: "mention"; value: string };
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Spezza il corpo del messaggio in testo semplice e menzioni, pronte per essere
|
||||||
|
* rese con uno stile diverso. Il valore di una menzione include la chiocciola:
|
||||||
|
* chi lo disegna non deve rimetterla e non deve sapere come è stata trovata.
|
||||||
|
*/
|
||||||
|
export function splitMentions(body: string, candidates: string[]): MentionPart[] {
|
||||||
|
if (!body) return [];
|
||||||
|
if (candidates.length === 0) return [{ type: "text", value: body }];
|
||||||
|
|
||||||
|
// Si lavora su array di code point, non sulla stringa: gli indici restano
|
||||||
|
// validi anche con emoji o caratteri fuori dal piano base.
|
||||||
|
const chars = Array.from(body);
|
||||||
|
const folded = chars.map(foldChar);
|
||||||
|
const foldedCandidates = candidates.map((c) => Array.from(c).map(foldChar));
|
||||||
|
|
||||||
|
const parts: MentionPart[] = [];
|
||||||
|
let plainFrom = 0;
|
||||||
|
let i = 0;
|
||||||
|
|
||||||
|
while (i < chars.length) {
|
||||||
|
if (chars[i] !== "@") {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const before = i > 0 ? chars[i - 1] : undefined;
|
||||||
|
if (before !== undefined && WORD_CHAR.test(before)) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const matched = foldedCandidates.find((cand) =>
|
||||||
|
matchesAt(folded, i + 1, cand)
|
||||||
|
);
|
||||||
|
if (!matched) {
|
||||||
|
i++;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const end = i + 1 + matched.length;
|
||||||
|
if (plainFrom < i) {
|
||||||
|
parts.push({ type: "text", value: chars.slice(plainFrom, i).join("") });
|
||||||
|
}
|
||||||
|
parts.push({ type: "mention", value: chars.slice(i, end).join("") });
|
||||||
|
plainFrom = end;
|
||||||
|
i = end;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (plainFrom < chars.length) {
|
||||||
|
parts.push({ type: "text", value: chars.slice(plainFrom).join("") });
|
||||||
|
}
|
||||||
|
return parts;
|
||||||
|
}
|
||||||
|
|
||||||
|
function matchesAt(
|
||||||
|
folded: string[],
|
||||||
|
start: number,
|
||||||
|
candidate: string[]
|
||||||
|
): boolean {
|
||||||
|
if (start + candidate.length > folded.length) return false;
|
||||||
|
for (let k = 0; k < candidate.length; k++) {
|
||||||
|
if (folded[start + k] !== candidate[k]) return false;
|
||||||
|
}
|
||||||
|
const after = folded[start + candidate.length];
|
||||||
|
return after === undefined || !WORD_CHAR.test(after);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Questo messaggio tagga il cliente? È il solo interruttore della notifica. */
|
||||||
|
export function hasMention(body: string, candidates: string[]): boolean {
|
||||||
|
return splitMentions(body, candidates).some((p) => p.type === "mention");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user