2e9bd2ab60
Nella Timeline — la vista di default — lo stato di un task era solo un cerchietto colorato: ambra col puntino da 1.5px per "in corso", violetto col puntino da 2px per "in revisione". A 20px sono lo stesso oggetto. Solo "in revisione" aveva un title; gli altri tre stati non avevano ne' testo, ne' tooltip, ne' aria-label — l'opposto della regola UX 3 del design system, che chiede colore + testo, mai colore da solo. Il punto pero' non era la somiglianza fra i due colori. "In revisione" non significa quello che dice5547e55, che lo descriveva come controllo qualita' interno: nel flusso reale vuol dire "consegnato, aspetta l'OK del cliente". E' uno stato che richiede un'azione, e per questo un cerchietto muto era il problema vero. - Le quattro icone si distinguono per forma, non solo per colore, quindi reggono anche in bianco e nero e con un daltonismo: vuoto -> punto -> spunta vuota -> spunta piena. La spunta compare a lavoro finito e si riempie a lavoro confermato. - Pill di testo solo su "in corso" e "in revisione". "Da fare" e' un cerchio vuoto e "fatto" ha il titolo barrato: etichettarli aggiungeva rumore, non informazione. - Il conteggio dei task in attesa sta nell'header della fase, quindi si legge anche a card chiusa. Serve davvero: l'admin puo' forzare una fase su "completata" dalla select, e in quel caso la card parte collassata con dentro la richiesta. - Legenda + micro-copy sopra entrambe le viste: chi revisiona, e che ogni pagina include un giro di revisione — l'informazione commerciale che il cliente non aveva da nessuna parte. Rimanda alla chat della fase, che esiste gia'. - Colonne del kanban cliente colorate con le stesse tinte dell'icona in Timeline: prima erano quattro colonne grigie identiche. - Icona aria-hidden + sr-only, cosi' lo screen reader annuncia lo stato anche sui due che non portano la pill. La barra di avanzamento non e' stata toccata: un task in revisione conta come uno in corso, cioe' zero. Cambia il tipo di lavoro, non l'avanzamento — gonfiare la percentuale le avrebbe fatto dire una cosa che il contatore "x di y task" smentiva una riga sotto. Visual e copy in un solo file, e la legenda si genera da TASK_STATUSES: un quinto stato non lascera' indietro una lista scritta a mano. Stessa disciplina di5547e55, nata proprio perche' due liste fisse avevano fatto sparire dei task senza un errore. Nessuna migration: e' solo UI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
494 lines
31 KiB
Markdown
494 lines
31 KiB
Markdown
# ClientHub (IAMCAVALLI) — Status
|
||
|
||
_Ultimo aggiornamento: 2026-08-22_
|
||
|
||
Questo è **l'unico documento narrativo** del progetto: a che punto siamo, cosa manca,
|
||
cosa abbiamo imparato. `.planning/STATE.md` è il digest che leggono i comandi
|
||
`/gsd-*` — non raddoppia questo file, ci rimanda.
|
||
|
||
## Stato attuale
|
||
|
||
In produzione su `hub.iamcavalli.net` (Coolify/Hetzner, deploy automatico su push a
|
||
`main` via Gitea). Build verde, `npm audit` pulito.
|
||
|
||
Milestone **v2.5 "Audit"** in **pausa** a Phase 27 a metà: in produzione c'è solo lo
|
||
schema dell'audit, nessuna pagina. La pausa è una scelta del 2026-08-19 — prima le
|
||
**modifiche all'hub** chieste il 2026-08-18, poi il motore.
|
||
|
||
| Milestone | Fasi | Stato |
|
||
|---|---|---|
|
||
| Modifiche hub | — | 🔨 in corso — dashboard e progetti in produzione, pipeline a metà |
|
||
| v2.5 Audit | 27–30 | ⏸️ in pausa — Phase 27 ~50% |
|
||
| v2.4 Post-vendita | 13, 26 | ✅ chiusa 2026-08-08, entrambe in produzione |
|
||
| v2.3 Email & Accesso | 23–25 | ✅ shipped 2026-07-29, verificata E2E |
|
||
| v2.2 Sales Loop | 18–22 | ✅ shipped 2026-06-20 |
|
||
| v2.1 Offer Studio + CRM | 11, 12, 14 | ✅ chiusa per reset 2026-06-19 |
|
||
| v1.0 + v2.0 | 1–10 | ✅ shipped giugno 2026 |
|
||
|
||
## In corso
|
||
|
||
### Modifiche hub (richieste 2026-08-18)
|
||
|
||
Tre aree: dashboard, progetti, pipeline. Piano in
|
||
`~/.claude/plans/sei-arrivato-qua-search-recursive-kettle.md`, **fuori dal repo**.
|
||
|
||
**In produzione dal 2026-08-19** (commit `4b135ce` → `19ed377`):
|
||
|
||
- **Progetti.** Via il tab Commenti (duplicava `/admin/conversazioni`, che è un
|
||
sovrainsieme stretto) e il timer dalla lista. Riepilogo soldi + avanzamento in testa
|
||
al progetto. **Timer per fase e task** — migration `0018`, `ON DELETE SET NULL` perché
|
||
cancellare un task non deve cancellare le ore lavorate.
|
||
- **Dashboard.** Inbox promossa in cima a piena larghezza, con da-quanto-aspetta e su
|
||
cosa è stato scritto. **Analytics per linea di prodotto** (Entry/Signature/Retainer,
|
||
letti dalla tassonomia). **Timeline delle consegne** con semaforo ritardo/anticipo,
|
||
scadenza dedotta da offerta + durata con override manuale `projects.due_date`.
|
||
- **Pipeline.** `POST /api/webhooks/lead`, un endpoint solo per form del sito e bridge;
|
||
regge payload piatto, `fields` annidati di Elementor e urlencoded, e non duplica chi
|
||
compila due volte. Provato contro il DB di produzione via tunnel, poi ripulito.
|
||
|
||
**In produzione dal 2026-08-20** (`3fcb10d`, `5547e55`) — due modifiche uscite dall'uso
|
||
reale del pannello, nessuna migration (verificato: `tasks.status` è `text` senza `CHECK`):
|
||
|
||
- **Rinomina di un valore di tassonomia** da `/admin/impostazioni`. Prima si poteva solo
|
||
aggiungere o eliminare: per cambiare nome a una fase bisognava cancellarla — il che la
|
||
strappava via da ogni servizio — e ricrearla a mano. `renamePoolValue` c'era già e
|
||
propagava ovunque tranne in un punto: `importOfferIntoProject` copia `services.fase`
|
||
dentro `phases.title` e poi ritrova la fase **confrontando i titoli**, perché
|
||
`phases.offer_phase_id` esiste in schema ma non viene mai popolata. Un rename fermo al
|
||
catalogo lasciava le fasi dei progetti col vecchio nome e al re-import ne nasceva una
|
||
duplicata. Ora propaga anche lì, con lo stesso match `trim`+`lowercase` del merge, ed
|
||
è l'unico rename che chiede conferma — dicendo quante fasi e quanti progetti tocca.
|
||
- **Stato task "In revisione"**, fra "In corso" e "Fatto", visibile anche al cliente. Il
|
||
lavoro vero non era il nuovo stato ma i tre letterali ricopiati a mano in otto file:
|
||
ora tutto deriva da `TASK_STATUSES` in `src/lib/task-status.ts`.
|
||
|
||
**In produzione dal 2026-08-20**, secondo giro (`b49d4bf`, `1fa8e1a`, migration `0019`):
|
||
|
||
- **Tab pagamenti.** Mettere una rata su "saldato" la faceva saltare in fondo: `payments`
|
||
non aveva **nessuna** colonna d'ordine e nessuna delle 13 query che la leggono aveva un
|
||
`ORDER BY`, quindi Postgres restituiva l'ordine fisico e una `UPDATE` in MVCC riscrive la
|
||
tupla in coda. In produzione **3 progetti su 5 erano già scombinati**. Per questo il
|
||
backfill della `0019` **non** ordina per `ctid` (avrebbe fotografato lo scombinamento) ma
|
||
per `percent DESC` con tie su `label`. Ora le rate si rinominano, gli importi si
|
||
sovrascrivono a mano (`amount_locked` li esclude dal ricalcolo) e lo scarto fra somma
|
||
rate e totale viene **dichiarato**, non aggiustato di nascosto. Schema a 3 rate: 50/25/25.
|
||
- **Riordino dei task** dentro la fase, trascinando. `PhasesTab` è un Server Component con
|
||
closure `"use server"` inline, quindi non è stato convertito: le righe restano
|
||
server-renderizzate e `SortableTaskList` ci monta attorno solo la maniglia.
|
||
|
||
Due bug chiusi per strada, entrambi vivi in produzione: `rescalePayments` azzerava le righe
|
||
con `percent` NULL appena una riga del progetto ne aveva uno (ed è proprio quello che
|
||
produce `splitPayment`), e il selettore di schema cancellava `status`/`paid_at` senza
|
||
chiedere, con due rate già saldate a rischio.
|
||
|
||
⚠️ **Provato a runtime, non ancora cliccato.** Build di produzione contro il DB vero via
|
||
tunnel SSH, in sola lettura: pagina progetto 200, 27 maniglie di trascinamento (esattamente
|
||
i task delle fasi con più di un task), rate nell'ordine giusto. Restano da esercitare a mano
|
||
le tre scritture nuove — `reorderTasks`, `updatePaymentField`, `clearPaymentOverride` — e il
|
||
drag vero e proprio.
|
||
|
||
⚠️ **Il giro precedente resta deployato ma non provato a mano.** Build e lint verdi, immagine
|
||
`9a57e45` viva, `/admin/login` risponde 200. Restano da esercitare in UI le due
|
||
interazioni: la matita di rinomina con il dialog di conferma, e il drag di un task in
|
||
"In revisione" con il controllo che la fase risulti *attiva*. Non sono state automatizzate
|
||
di proposito: **l'unico DB raggiungibile in locale è la produzione**, e una prova di
|
||
rinomina riscriverebbe titoli di fasi veri.
|
||
|
||
**In produzione dal 2026-08-21** (migration `0020`) — portale cliente:
|
||
|
||
- **Barra di avanzamento compatta e a tutta larghezza.** Rubava ~147px di altezza sopra
|
||
la piega per dire una cosa sola. Ora "Step N" e lo stato stanno sulla stessa riga, il
|
||
titolo della fase sotto — due righe invece di tre — e il padding scende da `py-8` a
|
||
`py-3`: ~88px. Il mock sta in `design-reference/Client-Portal-Progress-Bar`, scritto in
|
||
`slate-*` grezzi come tutti i mock: tradotto a token, non copiato.
|
||
- **Card offerta senza accordion.** Via "Cosa è compreso": la lista dei servizi non serve
|
||
al cliente e non viene più nemmeno **proiettata** — `client-view.ts` ora legge dai
|
||
servizi i soli prezzi. `OffersSection` ha perso il suo unico `useState` e con esso il
|
||
`"use client"`.
|
||
- **"Valore incluso" → "Valore dell'offerta", con override.** Era sempre la somma dei
|
||
prezzi di catalogo dei servizi del tier: un artefatto che si muove quando si muove il
|
||
listino. In produzione produceva **€20.250 su offerte vendute a 7.000 e 5.500** — al
|
||
cliente sembrava uno sconto del 70%. Ora `project_offers.offer_value_override` vince
|
||
sulla somma; NULL torna al calcolo, `0` nasconde la riga. Si gestisce dal tab Offerte
|
||
del progetto, che mostra accanto la cifra calcolata per far vedere cosa si sta
|
||
sostituendo. La somma vive in `src/lib/offer-value.ts`, letta **sia** dal portale sia
|
||
dall'admin: duplicarla avrebbe fatto divergere le due viste.
|
||
- **"Prezzo finale" → "Investimento finale"** (solo una tantum; il ricorrente resta
|
||
"Canone mensile").
|
||
|
||
✅ **Verificato quel che si poteva verificare.** Build verde, lint senza nuovi warning,
|
||
migration `0020` applicata a prod **prima** del push e colonna riletta dal DB a conferma;
|
||
deploy atterrato (l'immagine in esecuzione è taggata `44be190`, cioè HEAD). **La scrittura
|
||
dell'override è provata sul campo**: il 2026-08-21 in prod `Caruso Speaker / B` ha
|
||
`offer_value_override = 7500.00` a fronte di un calcolo di 20.250 — la `0020` aggiunge la
|
||
colonna e basta, non fa backfill, quindi quel numero è stato messo a mano dal tab Offerte.
|
||
|
||
⚠️ **Non reso a runtime, e va detto.** Le pagine non sono state aperte contro i dati veri:
|
||
`.env.local` è scaduto **su tre fronti** — `ADMIN_PASSWORD`, `NEXTAUTH_SECRET` **e la
|
||
password del DB** — e l'host che dichiara (`178.104.27.55:5432`) è chiuso dal firewall.
|
||
Estrarre la password viva dal container è stato **bloccato dal classifier dei permessi**, e
|
||
non è stato aggirato. Quindi: *scritto e buildato*, non *provato*. **Da fare a mano:**
|
||
aprire il portale di un cliente e guardare barra e card offerta. **Resta un override da
|
||
mettere**: `Rossi Inc / B` è venduta a 7.000 e il portale le mostra ancora i 20.250 del
|
||
calcolo. (`Teckell / A`: calcolo e venduto coincidono a 200, non serve toccarla.) Perché la verifica locale torni possibile serve
|
||
riallineare `.env.local` alle variabili di Coolify (vedi § Note tecniche).
|
||
|
||
Cosa manca, e perché:
|
||
|
||
- **[BLOCCANTE] TidyCal.** Non ha webhook — è scritto nella loro FAQ, la strada
|
||
suggerita è Zapier/Make. Ha una REST API con Personal Access Token su tutti i piani,
|
||
ma **path, filtri per data e paginazione stanno dietro il login** e non sono
|
||
indicizzati. Serve che l'utente apra `tidycal.com/integrations` → API Keys e passi
|
||
token o documentazione: dedurre la forma dell'API violerebbe la regola che il
|
||
progetto si è già dato dopo il caso PageSpeed.
|
||
- **Alleggerire l'hub.** Senza perimetro. Da affrontare guardando cosa è davvero poco
|
||
usato, ora che dashboard e progetti sono in produzione.
|
||
- **Whop → audit.** Dipende dal motore: oggi sarebbe un innesco che non innesca nulla.
|
||
|
||
✅ ~~Il tab Commenti permetteva di rispondere sulla singola entità;
|
||
`replyToConversation` salva sempre sul thread generale.~~ **Chiuso dalla chat a canali**
|
||
(sotto): la risposta va sull'entità del canale attivo.
|
||
|
||
### Chat a canali (2026-08-21, in produzione)
|
||
|
||
Il portale aveva **una** conversazione con un selettore di fase in un dropdown. Due
|
||
problemi veri: il cliente non aveva modo di vedere *dove* c'era del non letto, e una
|
||
risposta dell'admin poteva atterrare su un'entità diversa da quella della domanda.
|
||
|
||
Ora i messaggi si organizzano in **canali** — "Generale" più uno per fase. Il canale non
|
||
è una colonna: `comments` resta polimorfica (`entity_type` + `entity_id`) e la chiave si
|
||
*deriva*. La derivazione sta in un posto solo, `src/lib/chat-channels.ts`, funzioni pure
|
||
importate da entrambe le sponde — **perché se cliente e admin la calcolassero ognuno per
|
||
conto suo, una risposta finirebbe in un tab diverso da quello della domanda, che è
|
||
esattamente il bug che questo giro chiude.** Task e deliverable non sono più scrivibili,
|
||
ma lo storico non resta orfano: rientra nel canale della fase proprietaria, conservando
|
||
il nome dell'entità come badge sul messaggio.
|
||
|
||
**Il letto/non-letto è asimmetrico fra le due sponde, di proposito.** Il cliente ha una
|
||
tabella nuova, `client_channel_reads`, una riga per `(client_id, channel_key)`: un
|
||
singolo `client_last_read_at` segnerebbe letti *tutti* i canali all'apertura della chat,
|
||
e il pallino sul singolo tab — l'unica cosa che dice al cliente dove guardare — perderebbe
|
||
senso. L'admin invece resta su `clients.admin_last_read_at` (già esistente dalla `0014`):
|
||
lì la lettura è per conversazione e il pallino per canale si deriva da quel timestamp,
|
||
quindi nessuna tabella nuova. Il thread admin porta `adminLastReadAt` come **snapshot**
|
||
congelato al caricamento: senza, l'auto-mark-read all'apertura cancellerebbe i pallini
|
||
sotto gli occhi di chi sta leggendo.
|
||
|
||
Migration `0021`, additiva, applicata a prod **prima** del push e tabella riletta a
|
||
conferma. Dentro anche un indice su `comments (entity_id, created_at)`: la tabella non ne
|
||
ha mai avuto uno, **nemmeno su `entity_id`**, dal giorno 0 — e il feed si legge esattamente
|
||
così. Deploy atterrato, immagine taggata `ac74a81` = HEAD.
|
||
|
||
⚠️ **Buildata, non provata a mano** — stesso motivo di sopra (`.env.local` scaduto).
|
||
**Da fare:** aprire il portale di un cliente, scrivere su una fase, rispondere da
|
||
`/admin/conversazioni` e verificare che la risposta torni **in quel tab**.
|
||
|
||
### Modifica dei messaggi + firma in chat (2026-08-21, in produzione)
|
||
|
||
Due mancanze emerse **provando la chat in produzione** — che è esattamente il tipo di
|
||
cosa che né il build né il lint possono dire.
|
||
|
||
**Modifica dei messaggi**, 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, non una svista.** Illimitato + nessuno storico significa che
|
||
> un messaggio scritto mesi fa resta riscrivibile, e l'unica difesa dell'altra parte è
|
||
> quell'etichetta. Il rischio è stato posto e accettato il 2026-08-21. Se un domani
|
||
> servisse dimostrare cosa era stato scritto, la strada è una tabella `comment_edits`:
|
||
> additiva, si aggiunge senza toccare niente di quanto c'è ora.
|
||
|
||
**Il punto difficile non era scrivere la modifica, era propagarla.** Il poll chiede i
|
||
messaggi con `created_at > since`, e una modifica **non cambia `created_at`** — quindi
|
||
l'altra parte avrebbe continuato a vedere il testo vecchio fino a un ricaricamento
|
||
completo. Il filtro ora guarda **anche `edited_at`**, e il watermark del client è il
|
||
**massimo fra i due su tutti i messaggi**: se restasse il `created_at` dell'ultimo, il
|
||
server rispedirebbe lo stesso messaggio modificato a ogni giro, per sempre. Le due cose
|
||
vanno insieme — una sola delle due e o la modifica non arriva, o arriva all'infinito.
|
||
Il merge per id era già in piedi, quindi nessun duplicato da temere.
|
||
|
||
**Il non-letto resta ancorato a `created_at`, di proposito**: correggere un refuso non
|
||
deve riaccendere il pallino di un canale già letto. Annotato nel codice, altrimenti al
|
||
prossimo refactor sembra una svista da "sistemare".
|
||
|
||
Autorizzazione: **si modifica solo ciò di cui si è autori** — il controllo è su
|
||
`author`, non solo sulla proprietà dell'entità, e lo rifà il server (il pulsante
|
||
nascosto non è una difesa). Migration `0022`, additiva, applicata a prod prima del push
|
||
e riletta a conferma: 11 messaggi, 0 marcati come modificati.
|
||
|
||
**Firma in chat.** Il nome era la stringa `iamcavalli` cablata nel pannello: il cliente
|
||
leggeva il marchio dove si aspetta una persona, e il monogramma diceva `IA`. Ora nome e
|
||
foto arrivano da `settings` — **nessuna migration**, la tabella è già un key/value con i
|
||
suoi helper — e si impostano da `/admin/impostazioni`.
|
||
|
||
La foto è un **URL esterno**, e non è un ripiego: `/api/uploads/[...path]` **non esiste**.
|
||
La deroga al vincolo LOCKED #5 per le immagini dell'audit è scritta in CLAUDE.md ma il
|
||
codice non è mai stato costruito (Phase 27 è ferma allo schema). Un upload vero avrebbe
|
||
richiesto di modificare un vincolo LOCKED *e* costruire da zero volume persistente,
|
||
Server Action, rotta di lettura, whitelist MIME e limite di dimensione. Avatar assente o
|
||
rotto ricade sul monogramma, così un link che muore non lascia un buco.
|
||
|
||
Nell'inbox admin il nome **non** cambia: lì i propri messaggi dicono «Tu (Admin)», che è
|
||
già corretto. Il problema era solo come il cliente vede l'altra parte.
|
||
|
||
⚠️ **Buildata, non provata a mano** (`.env.local` scaduto). **Da fare, in quest'ordine:**
|
||
(1) con la chat del cliente **aperta**, modificare da `/admin/conversazioni` un messaggio
|
||
dell'admin in quel canale — entro ~20s il testo deve cambiare da solo, senza duplicarsi;
|
||
(2) verificare che il pallino **non** si riaccenda; (3) impostare nome e foto in
|
||
`/admin/impostazioni` e ricontrollare il portale; (4) da `?preview=1` il pulsante
|
||
«Modifica» non deve esserci.
|
||
|
||
**Rimandato, con la ragione scritta:** le **menzioni** con più persone invitate in un
|
||
portale. L'ostacolo non sono le notifiche ma l'attribuzione — `comments.author` contiene
|
||
solo `client`/`admin`, non *quale* persona, quindi oggi tre invitati sono tre «Tu»
|
||
identici. L'identità però esiste già: la sessione OTP porta l'email
|
||
(`ClientSession = { clientId, email, iat }`) e `client_emails` è la lista degli invitati.
|
||
Manca che le scritture della chat leggano la sessione invece del solo token — il lavoro
|
||
già annotato in `client-chat.ts` come «un lavoro a sé». Ordine giusto: **attribuzione**,
|
||
poi notifiche (Resend c'è già), poi eventualmente menzioni. Molto probabilmente vedere
|
||
*chi* ha scritto risolve gran parte del problema da solo.
|
||
|
||
### Stati dei task, resi leggibili nel portale (2026-08-22)
|
||
|
||
Nella Timeline — la vista di default — lo stato di un task era **solo un cerchietto
|
||
colorato**: ambra col puntino da 1.5px per «In corso», violetto col puntino da 2px per
|
||
«In revisione». A 20px sono lo stesso oggetto. Solo «In revisione» aveva un `title`; gli
|
||
altri tre stati non avevano né testo, né tooltip, né `aria-label`. Il design system chiede
|
||
l'opposto, ed è la sua regola UX 3: **colore + testo, mai colore da solo**.
|
||
|
||
**«In revisione» non significa quello che dice il commit che l'ha introdotto.** `5547e55`
|
||
lo descriveva come controllo qualità interno («finito, ma da controllare prima di
|
||
consegnarlo»). Nel flusso reale è **«consegnato, aspetta l'OK del cliente»**: la palla è al
|
||
cliente. Un cerchietto muto per uno stato che *richiede un'azione* era il problema vero,
|
||
più della somiglianza fra i due colori.
|
||
|
||
Cosa è cambiato:
|
||
|
||
- **Le quattro icone si distinguono per forma**, non solo per colore, quindi reggono anche
|
||
in bianco e nero e con un daltonismo: vuoto → punto → **spunta vuota** → spunta piena. La
|
||
spunta compare quando il lavoro è finito e si riempie quando è confermato.
|
||
- **Pill di testo solo su «In corso» e «In revisione».** «Da fare» è un cerchio vuoto e
|
||
«Fatto» ha il titolo barrato: etichettarli aggiungeva rumore senza aggiungere informazione.
|
||
- **Il conteggio dei task in attesa sta nell'header della fase**, accanto a «4 di 8 task»,
|
||
quindi si legge anche a card chiusa. Serve davvero: l'admin può forzare una fase su
|
||
«Completata» dalla select, e in quel caso la card parte collassata con dentro la richiesta.
|
||
- **Legenda + micro-copy** sopra entrambe le viste, che dice chi revisiona e che **ogni
|
||
pagina include un giro di revisione** — l'informazione commerciale che il cliente non
|
||
aveva da nessuna parte. Rimanda alla chat della fase, che esiste già.
|
||
- **Colonne del Kanban cliente colorate**, con la stessa tinta dell'icona in Timeline:
|
||
prima erano quattro colonne grigie identiche.
|
||
- **Lo screen reader legge lo stato su tutti e quattro** (icona `aria-hidden` + `sr-only`),
|
||
non solo sui due che portano la pill.
|
||
|
||
**La barra di avanzamento non è stata toccata.** Un task in revisione conta come uno in
|
||
corso — cioè zero. Cambia il *tipo* di lavoro, non l'avanzamento: gonfiare la percentuale
|
||
avrebbe fatto dire alla barra una cosa che il contatore «x di y task» smentiva una riga
|
||
sotto.
|
||
|
||
Visual e copy stanno in un solo file, `src/components/client/TaskStatusIndicator.tsx`, e la
|
||
legenda si genera da `TASK_STATUSES`: aggiungere un quinto stato non lascia indietro una
|
||
lista scritta a mano. È la stessa disciplina di `5547e55`, nata proprio perché due liste
|
||
fisse avevano fatto sparire dei task dal portale **senza un errore**.
|
||
|
||
⚠️ **Scritto e compilato, non ancora visto a schermo.** Build e typecheck verdi, lint
|
||
invariato (12 errori preesistenti, nessuno nei file toccati). Nessuna migration: è solo UI.
|
||
|
||
### v2.5 — Audit (Phases 27–30), in pausa
|
||
|
||
Il servizio di analisi sito (tre livelli: **Radiografia / Prima-Dopo / Rotta**) diventa
|
||
un documento privato su `/audit/[slug]`, generato da un motore multi-agente e rifinito a
|
||
mano prima della consegna. I tre livelli sono **configurazioni di un unico documento**:
|
||
i blocchi non pertinenti non esistono nel DOM.
|
||
|
||
Fatto finora (Phase 27, ~50%):
|
||
|
||
- **[prod 2026-08-18] Schema.** Migration `0017_audits.sql`, 7 tabelle additive, più
|
||
`checklist_items` seminata con 264 voci. Nessuna UI le legge ancora.
|
||
- **[prod 2026-08-19, inerte] Le fonti del motore.** `src/lib/audit/sources/` —
|
||
PageSpeed, CrUX, Wayback, RDAP, robots/sitemap/JSON-LD, header. Provate sul campo su
|
||
giojello.com: giro completo in 73 s, tutte e cinque hanno risposto. Deployate, ma
|
||
**nessuna route le chiama**.
|
||
|
||
Manca: agent + sintetizzatore + pipeline, storage immagini, editor admin, pagina
|
||
pubblica. Dettaglio in [`.planning/ROADMAP.md`](.planning/ROADMAP.md) e
|
||
[`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md).
|
||
|
||
⚠️ **I due piani della milestone stanno in `~/.claude/plans/`, fuori dal repo**
|
||
(`…woolly-puddle.md` per il documento, `…radiant-valley.md` per il motore). Senza quei
|
||
file la milestone non è ricostruibile.
|
||
|
||
## Fatto
|
||
|
||
### v2.4 — Post-vendita
|
||
|
||
- **[Phase 13, prod 2026-08-01] Ciclo di vita dei servizi ricorrenti.**
|
||
`project_offers.status` (attivo/sospeso/cessato) + `end_date` (migr. 0016). Prima
|
||
un retainer non poteva finire e il forecast lo sommava a ogni mese in eterno.
|
||
Comandi Sospendi/Riattiva/Cessa nella tab Offerte; il cliente vede stato, "attivo
|
||
dal" e canone mensile. Dettaglio: [`.planning/phases/13-…/13-SUMMARY.md`](.planning/phases/13-ciclo-vita-servizi-ricorrenti/13-SUMMARY.md)
|
||
- **[Phase 26, prod 2026-08-08] Anteprima admin del portale + toggle password.**
|
||
`?preview=1` più una sessione Auth.js valida apre il portale di un cliente in
|
||
**sola lettura**, senza passare dal gate OTP. Deviazione consapevole dal vincolo
|
||
LOCKED #4, annotata in `CLAUDE.md`. Dettaglio: [`.planning/phases/26-…/26-SUMMARY.md`](.planning/phases/26-anteprima-admin-e-login/26-SUMMARY.md)
|
||
|
||
### v2.3 — Email & Accesso
|
||
|
||
- **Gate OTP sul portale cliente**: whitelist email per cliente (`client_emails`),
|
||
codice a 6 cifre via Resend, sessione firmata 90 giorni, revoca in blocco
|
||
dall'admin (`clients.sessions_valid_from`). Migr. 0015. Verificato E2E in
|
||
produzione. Archivio: [`v2.3-ROADMAP.md`](.planning/milestones/v2.3-ROADMAP.md)
|
||
|
||
### Prima di v2.3
|
||
|
||
- **[2026-07] Audit di sicurezza chiuso**: 4 vulnerabilità risolte e deployate, slug
|
||
clienti ruotati a 12 char CSPRNG, `INTERNAL_SECRET` e `ADMIN_PASSWORD` configurati
|
||
su Coolify. Report in [`.planning/security/`](.planning/security/).
|
||
- **Design system "Quiet Luxury"**: dashboard, liste (Clienti/Offerte/Catalogo/
|
||
Preventivi/Progetti), Conversazioni, Impostazioni, Pipeline+Kanban, dettaglio Lead
|
||
e portale cliente base sono a token semantici e dual-theme.
|
||
- **Tassonomie centralizzate** in Impostazioni (modello Notion, pool persistenti
|
||
`src/lib/taxonomy.ts`).
|
||
- **Lead → Cliente**: `clients.email/phone` + `leads.archived` (migr. 0011).
|
||
`convertLeadToClient` riusa `createClientCore`, porta i transcript, archivia il
|
||
lead mantenendo "won".
|
||
- **Offerta → Fasi/Task**: `importOfferIntoProject` crea fasi raggruppando i servizi
|
||
del tier per `services.fase`.
|
||
- **Offerte (modello + UI)**: `offer_macros.offer_type` ('una_tantum'|'retainer') +
|
||
toggle "Modalità" nell'editor (migr. 0012). Tab Offerte a 2 step.
|
||
|
||
## Da fare
|
||
|
||
- [ ] **Whitelist portale**: dei 4 clienti solo uno ha email autorizzate. Chi ha la
|
||
whitelist vuota **non entra nel proprio portale** — si popola da
|
||
`/admin/clients/<id>` → "Accessi al portale", poi va reinviato il link.
|
||
- [ ] **Fasi/Task dall'offerta** funzionano solo se i servizi hanno il campo **Fase**
|
||
valorizzato nel Catalogo (altrimenti finiscono in "Generale").
|
||
- [ ] **Debito design (DEBT-01)**: **~40 file, ~450 occorrenze** di palette Tailwind
|
||
raw e hex literal invece dei token semantici. I cluster: `/admin/projects/[id]`
|
||
e i suoi tab (~182), `/admin/offers/[id]/edit` (~79), `/admin/clients/[id]`
|
||
(~59), tutto `/quote/[token]` (~48, ed è rivolto al cliente), `ChatPanel` (37),
|
||
più `ui/dialog.tsx` che propaga il look vecchio a ogni modale. *Esclusi perché
|
||
legittimi:* `AdminSidebar` (eccezione brand documentata), `src/lib/mailer.ts`
|
||
(HTML email), i colori di stato di `StatusBadge` (sanzionati dal design system,
|
||
hanno già le varianti `dark:`).
|
||
- [ ] Micro legacy "Mantenimento" senza tier: valutare se rimuoverlo/normalizzarlo.
|
||
- [ ] **Backlog**: canoni mensili tracciabili (RET-06 — serve una tabella nuova),
|
||
PROP-03 (Stripe sul deck), PROP-04 (auto-provisioning al "Vinto"), SEND-01/02
|
||
(invio preventivo via email — il mailer è già pronto). Elenco completo in
|
||
[`.planning/REQUIREMENTS.md`](.planning/REQUIREMENTS.md).
|
||
|
||
## Lezioni operative
|
||
|
||
Cose imparate a caro prezzo. Non sono documentazione di feature: sono trappole in
|
||
cui si ricasca.
|
||
|
||
- **Il gate OTP non va nel layout.** Prima implementazione: gate in
|
||
`client/[token]/layout.tsx` che rendeva `<OtpGate/>` al posto di `{children}`.
|
||
**Non protegge nulla.** Nell'App Router il segmento `page` è renderizzato in
|
||
parallelo al layout: la dashboard spariva a schermo ma fasi, task e pagamenti
|
||
restavano leggibili nel payload RSC dell'HTML (46.907 byte → 17.594 dopo il fix).
|
||
Il gate sta in cima alla `page`, prima di ogni query, via `getClientGate()`.
|
||
**Ogni nuova route sotto `/client/[token]/` deve fare lo stesso.**
|
||
- **Ricreare il dominio su Resend rigenera la chiave DKIM.** Se il dominio torna
|
||
`failed`, non fidarsi di valori DKIM annotati in passato: rileggerli da
|
||
`GET /domains` e confrontarli con
|
||
`dig +short TXT resend._domainkey.iamcavalli.net @8.8.8.8`.
|
||
- **`.env.local` non è allineato a produzione, e la deriva è peggiorata.** Il 2026-07-28
|
||
erano stati ruotati **solo su Coolify** `ADMIN_PASSWORD` e `NEXTAUTH_SECRET`; il
|
||
2026-08-21 si è scoperto stale anche **la password del DB** (Postgres risponde
|
||
`password authentication failed`) e chiuso dal firewall l'host che il file dichiara,
|
||
`178.104.27.55:5432` — il DB vero è su `127.0.0.1:54321` dietro tunnel, quindi vanno
|
||
riscritti host **e** porta. Recuperare la password viva dal container è **bloccato dal
|
||
classifier**: non si aggira, si chiede all'utente di riallineare il file. Conseguenza
|
||
operativa: finché resta così, **una modifica al portale si verifica solo guardandola in
|
||
produzione** — e va detto, invece di far passare "buildato" per "funziona". Le migration
|
||
non ne soffrono (`docker exec` non usa quelle credenziali).
|
||
- **L'API Coolify rifiuta `is_build_time`** con 422 sul POST a
|
||
`/api/v1/applications/<uuid>/envs`: mandare solo `key`, `value`, `is_preview`.
|
||
- **Una lista di valori validi dimentica in silenzio, una negazione no.**
|
||
`recomputePhaseStatus` decideva "fase iniziata" con `status === "in_progress" ||
|
||
status === "done"`. Aggiungendo "In revisione", una fase con tutti i task in revisione
|
||
non rientrava né in `allDone` né in `anyActive` e **retrocedeva a "upcoming"**: si
|
||
leggeva "non iniziata" quando era quasi finita. Stesso schema in `ClientKanban`, dove
|
||
i task erano ripartiti da un oggetto a tre chiavi fisse invece che dalle colonne: un
|
||
task fuori da quelle spariva da ogni colonna e da ogni contatore, e il cliente ne
|
||
vedeva meno di quanti ce n'erano, **senza errore**. La causa a monte di entrambi era
|
||
lo stesso `as` al confine del portale, che TypeScript non verifica. Quando un insieme
|
||
di stati può crescere: derivare le colonne dalla costante, scrivere il predicato come
|
||
negazione, e normalizzare al confine invece di castare.
|
||
- **Playwright non funziona contro `npm run dev`**: la CSP blocca `eval` e i client
|
||
component non si idratano. Serve il build di produzione.
|
||
- **Le API di Google cambiano forma sotto i piedi, e in silenzio.** Scrivendo
|
||
`src/lib/audit/sources/` due estrazioni dedotte dalla documentazione hanno restituito
|
||
valori vuoti *senza errore*: `largest-contentful-paint-element` non esiste più (ora è
|
||
`lcp-breakdown-insight`, con `subpart`/`duration` e senza percentuali) e
|
||
`configSettings.screenEmulation` non esiste affatto nelle risposte pubbliche. Trovate
|
||
solo perché le fonti sono state fatte girare su un sito vero prima di costruirci sopra.
|
||
**Ogni estrazione da un'API di terzi va vista funzionare, non dedotta dai docs.**
|
||
- **Laboratorio e campo misurano cose diverse, e la differenza *è* il risultato.** Su
|
||
giojello.com Lighthouse dà `server-response-time` **7 ms** e CrUX dà TTFB p75 **3.553
|
||
ms con l'1% di visite nel verde**: il server risponde in fretta al datacenter Google e
|
||
lento a tutti gli altri. Due numeri con lo stesso nome verrebbero fusi in uno — da qui
|
||
`risposta_server_ms` invece di `ttfb_ms`.
|
||
- **I punteggi PageSpeed ballano fra un giro e l'altro**: performance mobile 52 e poi 64
|
||
sullo stesso sito a 30 minuti di distanza. Nei documenti consegnati un punteggio va
|
||
sempre con la sua data, mai presentato come una costante del sito.
|
||
|
||
## Note tecniche
|
||
|
||
- **Non esiste un database di sviluppo.** Quello puntato da `.env.local` **è** la
|
||
produzione: qualunque cosa si esegua in locale scrive su dati reali — verificare i
|
||
conteggi delle tabelle protette prima e dopo ogni prova. ⚠️ Dal 2026-08-21 quelle
|
||
credenziali **non autenticano più** e l'host che il file dichiara è chiuso: per
|
||
raggiungere il DB servono il tunnel su `127.0.0.1:54321` **e** una password viva presa
|
||
da Coolify. Fino ad allora `docker exec` via SSH è l'unica via.
|
||
- **Controllare che un deploy sia atterrato**: `docker ps` sul server mostra l'immagine, e
|
||
**il tag È lo SHA del commit** — si confronta con `git rev-parse --short HEAD`. Coolify
|
||
ci mette ~2 minuti e cambia il suffisso del container a ogni giro: un controllo immediato
|
||
fa credere a torto che il webhook sia rotto.
|
||
- **Migrazioni**: SQL scritto a mano in `src/db/migrations/` (`drizzle-kit generate`
|
||
è rotto — vanno tenuti in sync `schema.ts` e l'SQL). Si applicano **da locale via
|
||
SSH + docker exec**, senza tunnel — procedura completa in `CLAUDE.md`. Il tunnel
|
||
`ssh -f -N -L 54321:localhost:54321` serve solo per puntare il tooling locale
|
||
(es. `npx tsx`) al DB di prod.
|
||
- **Ordine di deploy con schema**: applicare la migrazione a prod **prima** del push
|
||
(il deploy fa girare subito il codice nuovo).
|
||
- **Coolify API**: credenziali in `~/.coolify.env` (formato `export VAR=…`, va
|
||
sorgentato). App uuid `xsksow44g4kcoo8wocsgkscc`.
|
||
- **`overrides` in package.json** forzano `postcss >= 8.5.18` e `sharp >= 0.35.0`:
|
||
le versioni che Next si porta dietro hanno CVE high e non c'è fix upstream. Se un
|
||
aggiornamento di Next rompe qualcosa, è il primo posto dove guardare.
|
||
- `offer_micros` non ha `created_at` (no "tier più vecchio" affidabile).
|
||
- **Debito tecnico non bloccante**: tabelle legacy `service_catalog` /
|
||
`offer_services` / `offer_micro_services` come deadweight; `createService` /
|
||
`serviceSchema` dead code in `src/app/admin/catalog/actions.ts`.
|
||
|
||
## File chiave
|
||
|
||
| File | Scopo |
|
||
|---|---|
|
||
| src/proxy.ts | Middleware (Next 16 lo chiama `proxy`, non `middleware`) |
|
||
| src/lib/client-gate.ts | Gate OTP — va chiamato in cima a ogni page sotto `/client/[token]/` |
|
||
| src/lib/otp.ts, client-session.ts | Codici OTP e cookie di sessione 90gg |
|
||
| src/lib/mailer.ts | Unico punto di invio email (Resend) |
|
||
| src/lib/forecast-queries.ts | Forecast 12 mesi — rispetta stato e `end_date` dei retainer |
|
||
| src/lib/taxonomy.ts | Pool tassonomie (Impostazioni) |
|
||
| src/app/admin/projects/project-actions.ts | `importOfferIntoProject`, `setProjectOfferLifecycle`, piani pagamento |
|
||
| src/components/admin/tabs/OffersTab.tsx | Tab Offerte + comandi ciclo di vita |
|
||
| src/lib/admin-queries.ts / client-view.ts | I due layer separati: admin vs proiezioni client-safe |
|
||
| src/lib/audit/sources/ | Le 5 fonti del motore audit (nessun LLM) — `fetch` espone anche gli helper di rete condivisi |
|
||
| src/db/migrations/ | 0011 (email/phone), 0012 (offer_type), 0015 (OTP), 0016 (ciclo di vita), 0017 (audit) |
|
||
|
||
## Dove sta il resto
|
||
|
||
| Cosa | Dove |
|
||
|---|---|
|
||
| Regole per Claude, procedure deploy/DB, vincoli LOCKED | `CLAUDE.md` |
|
||
| Digest di stato per i comandi `/gsd-*` | `.planning/STATE.md` |
|
||
| Requisiti e backlog della milestone corrente | `.planning/REQUIREMENTS.md` |
|
||
| Roadmap e storico milestone | `.planning/ROADMAP.md`, `.planning/MILESTONES.md` |
|
||
| Archivi delle milestone chiuse | `.planning/milestones/` |
|
||
| Fasi della milestone in corso | `.planning/phases/` |
|
||
| Report dell'audit di sicurezza (chiuso) | `.planning/security/` |
|
||
| Design system e mock per pagina | `design-reference/` |
|