chore(claude): architettura base .claude — skill preventivo e audit, hook di guardia, piani nel repo

La cartella aveva dentro solo rules/ e i settings: nessun posto dove mettere una
skill, un hook o un piano. Ora ha lo scheletro completo e un .claude/CLAUDE.md che
spiega cosa va dove — non duplica CLAUDE.md di progetto, che resta quello che comanda.

Due skill locali (le altre restano globali in ~/.claude/skills/):
- /preventivo — la catena agent.ts → schema.ts → assemble.ts → ProposalDeck e i tre
  modi di romperla, di cui uno solo fa rumore. Nessun prompt di generazione qui
  dentro: quello vive in agent.ts ed e' l'unico. Porta check-profilo.sh.
- /audit — guida scripts/audit-fonti.ts, nuovo, che mette in moto le cinque fonti di
  src/lib/audit/sources/, in prod dal 2026-08-19 ma mai chiamate da nessuno. Provate
  su giojello.com: 5 su 5, 42,7 s, PageSpeed mobile 58 / desktop 93.

Due hook, provati a mano (6 casi il primo, 5 il secondo):
- guardia-migration.sh BLOCCA l'SQL distruttivo sulle entita' protette — il vincolo
  Data Safety (LOCKED) fatto rispettare dalla macchina invece che dalla memoria.
- guardia-token.sh AVVISA sulle classi Tailwind grezze. Non blocca: con ~450
  occorrenze di debito, bloccare lo renderebbe un ostacolo da disattivare.

I tre piani di v2.5 entrano nel repo: stavano solo in ~/.claude/plans/ e STATE.md
avvertiva che senza quelli la milestone non era ricostruibile. Passati al setaccio
per credenziali prima di committarli.

Corretta in rules/memory-discipline.md la chiave della memoria persistente: e'
…-Vault-IAMCAVALLI-hub, non quella del workspace. Sedici file stavano nella prima,
la regola indicava la seconda.

Impeccable resta abilitato solo a livello globale: fuori da settings.json locale.

Nessun tocco al prodotto. Build e lint verdi, lint identico al baseline.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-26 16:09:14 +02:00
parent bec7e039d7
commit 817a8cd5d1
22 changed files with 1601 additions and 24 deletions
+319
View File
@@ -0,0 +1,319 @@
# Modifiche all'hub — dashboard, progetti, pipeline
## Contesto
Il 2026-08-18 hai elencato una serie di modifiche all'hub divise per sezione
(dashboard, progetti, pipeline). La sessione si è fermata sul limite settimanale
subito dopo aver lanciato l'esplorazione, e quel lavoro non è mai stato ripreso.
Questo piano lo raccoglie.
Il punto di partenza è che **quasi tutto quello che chiedi è calcolabile con i
dati che l'hub già ha**: le categorie di offerta esistono, le fasi e i task hanno
uno stato, i pagamenti hanno una data di incasso, le conversazioni sono già
aggregate. Serve poco schema nuovo — una sola migration additiva — e molta
query + UI.
L'eccezione è l'ingresso da Whop per gli audit: dipende dal motore di analisi
della v2.5, che è fermo alle sole fonti. Hai scelto di fare **prima le modifiche
hub e poi il motore**, quindi quel blocco resta documentato ma non costruito.
### Decisioni prese
| Domanda | Scelta |
|---|---|
| Sequenza | Hub prima, motore audit dopo |
| Fonte "Audit" negli analytics | È la tua **Entry Offer** — si legge da `offer_macros.category`, come Signature e Retainer |
| Data di consegna attesa | **Derivata** da offerta + durata, con **override manuale** opzionale |
| Ingresso lead | TidyCal + form sito — vedi il blocco C, dove c'è un vincolo che cambia le carte |
---
## Blocco A — Progetti
Il blocco a rischio più basso e a resa più immediata. Da fare per primo.
### A1 · Togliere il tab Commenti e il timer dalla lista
Due rimozioni chieste esplicitamente.
- **Tab "Commenti"** in [page.tsx:75](src/app/admin/projects/[id]/page.tsx#L75) e
[page.tsx:118-120](src/app/admin/projects/[id]/page.tsx#L118-L120), più l'import a riga 8.
**Non si perde niente**: ho verificato che `buildEntityMap()` in
[conversations-queries.ts:57](src/lib/conversations-queries.ts#L57) cammina
clienti → progetti → fasi → task → deliverable e raccoglie *tutti* i commenti
con la loro etichetta d'entità. `/admin/conversazioni` è un sovrainsieme
stretto di quel tab. Il campo `comments` in `ProjectFullDetail` diventa morto:
va tolto anche da [admin-queries.ts:567](src/lib/admin-queries.ts#L567) e dalla query.
- **Colonna "Timer"** dalla lista progetti: `<TimerCell>` in
[ProjectRow.tsx:85](src/components/admin/ProjectRow.tsx#L85) e l'intestazione in
[projects/page.tsx:47](src/app/admin/projects/page.tsx#L47). Il timer resta dove
ha senso, dentro il progetto.
### A2 · Mini-dashboard nel singolo progetto
Una striscia sopra i tab, prima di `<Tabs>` in
[projects/[id]/page.tsx:69](src/app/admin/projects/[id]/page.tsx#L69). **Nessuna
query nuova**: `getProjectFullDetail` restituisce già tutto.
**Finance** — incassato / contrattualizzato / residuo, da `payments` +
`offersAcceptedTotal`; ore tracciate e €/h reale da `totalTrackedSeconds` e
`targetHourlyRate` (già passato alla pagina a riga 24).
**Avanzamento** — task `done` su totale, con barra. Stessa aritmetica del
`progress_pct` già usato per le fasi.
Riusa il pattern `MetricCard` di [admin/page.tsx:18](src/app/admin/page.tsx#L18):
va estratto in `src/components/admin/MetricCard.tsx` e importato da entrambe.
Scrivere la striscia **a token semantici** (`bg-card`, `text-muted-foreground`) —
questa route è il cluster peggiore di DEBT-01 (~182 occorrenze di palette raw) e
non va peggiorata.
### A3 · Timer per task e fase
Oggi `time_entries` ha solo `project_id`
([schema.ts:245](src/db/schema.ts#L245)). Servono due colonne nullable.
- **Migration `0018`** (dettagli in fondo): `phase_id`, `task_id` su
`time_entries`, entrambe `ON DELETE SET NULL`. Cancellare un task **non deve**
cancellare il tempo tracciato: è storico fatturabile, l'entry ricade a livello
progetto. `project_id` resta obbligatoria, quindi ogni entry è sempre
attribuita.
- **Server actions** in [timer-actions.ts](src/app/admin/timer-actions.ts):
`startTimer` prende `phaseId`/`taskId` opzionali; stessa cosa per
`addManualTimeEntry`. La regola "un solo timer attivo alla volta" (righe 19-38)
resta com'è — vale globalmente, non per task.
- **UI**: `<TimerCell>` su ogni riga task dentro
[PhasesTab.tsx](src/components/admin/tabs/PhasesTab.tsx), con il totale della
fase come somma dei suoi task. `TimerTab` continua a mostrare il totale progetto.
---
## Blocco B — Dashboard
### B1 · Analytics per linea di prodotto
Tre righe — **Entry (Audit), Signature, Retainer** — lette da
`offer_macros.category`, che è già una taxonomy editabile da
`/admin/impostazioni` ([taxonomy.ts:37](src/lib/taxonomy.ts#L37)). In produzione
oggi ci sono esattamente quelle tre categorie.
Per categoria:
- **Iniziati questo mese** — `count(project_offers)` con `start_date` nel mese corrente.
- **Totale anno** — `count` + somma `accepted_total` con `start_date` nell'anno.
- **Incassato** — `payments` in stato `saldato` con `paid_at` nell'anno.
L'incasso ha un problema di attribuzione da risolvere esplicitamente: i pagamenti
stanno sul **progetto**, non sull'offerta. La regola:
1. progetto con **una** offerta → tutto l'incasso va a quella categoria;
2. progetto con **più** offerte → ripartito in proporzione ai rispettivi `accepted_total`;
3. progetto **senza** offerta → riga separata **"Senza offerta"**, mostrata a video.
Il terzo caso non va nascosto. Oggi in prod ci sono 5 progetti e 2 righe in
`project_offers`: la maggior parte dell'incassato finirebbe lì, e vederlo è il
modo per accorgersene.
Nuovo modulo `src/lib/product-analytics.ts` (non gonfiare `analytics-queries.ts`),
nuovo componente `src/components/admin/dashboard/ProductBreakdown.tsx`.
> **Nota da verificare al primo giro:** l'unica Entry Offer in produzione si
> chiama *"Sblocca Business"*. Se l'audit è un prodotto diverso, va creata la sua
> `offer_macro` con categoria `Entry Offer` — è configurazione, non codice.
### B2 · Timeline delle consegne
Riguarda i progetti che **si consegnano** — Entry e Signature. I retainer sono
continuativi e non hanno una consegna: restano fuori, filtrando su
`offer_macros.offer_type = 'una_tantum'`.
Per ogni progetto non archiviato:
- **Consegna attesa** = `projects.due_date` se valorizzata, altrimenti
`project_offers.start_date + offer_micros.duration_months`. La colonna
`due_date` arriva con la migration 0018 ed è l'override manuale che hai chiesto.
- **% completamento** = task `done` / task totali.
- **% tempo trascorso** = `(oggi inizio) / (consegna inizio)`.
- **Semaforo** — soglia ±10 punti: `in linea` dentro la banda, `in ritardo` se il
completamento è sotto, `in anticipo` se è sopra. Un progetto oltre la data di
consegna e non completo è `in ritardo` a prescindere.
I progetti senza scadenza calcolabile vanno elencati sotto come **"senza
scadenza"** invece di sparire — altrimenti un progetto senza offerta assegnata
diventa invisibile proprio nella vista che dovrebbe segnalarlo.
Query in `src/lib/delivery-queries.ts`, componente
`src/components/admin/dashboard/DeliveryTimeline.tsx`. `StatusBadge` per il
semaforo, Geist Mono per date e percentuali.
### B3 · Inbox
**Questo esiste già.** [MessagesWidget.tsx](src/components/admin/dashboard/MessagesWidget.tsx)
mostra i clienti con messaggi non letti, l'anteprima dell'ultimo messaggio e il
link "Rispondi →" verso `/admin/conversazioni`. È il widget stretto in colonna
1/3, in fondo: probabilmente non l'hai visto perché sta sotto la piega.
Quindi non si costruisce, si promuove:
- fascia **a piena larghezza in cima** alla dashboard, sopra i KPI, e solo quando
c'è almeno un non letto;
- 6 righe invece di 4, con **data relativa** e **etichetta dell'entità**
(`lastMessageAt` e `lastEntityLabel` sono già in `ConversationSummary`,
[conversations-queries.ts:8](src/lib/conversations-queries.ts#L8) — oggi non
vengono usati);
- anteprima più lunga del messaggio.
Nuovo layout in [admin/page.tsx](src/app/admin/page.tsx): Inbox → KPI →
Timeline consegne → Prodotti → il resto.
---
## Blocco C — Pipeline: ingresso lead
### Il vincolo che cambia le carte
**TidyCal non ha webhook.** È scritto nella loro FAQ: nessun supporto nativo, e
la strada suggerita è passare da Zapier / Make / Pabbly. Quello che TidyCal *ha*
è una REST API con OAuth 2.0 e **Personal Access Token disponibile su tutti i
piani** (si crea da `tidycal.com/integrations/oauth`), con endpoint sulle
prenotazioni.
Elementor Pro, invece, ha un'azione **Webhook** nativa in "Actions After Submit":
si incolla un URL e lui fa POST dei campi del form. Se invece andate su Astro,
il POST lo scrivete voi. In entrambi i casi è una chiamata in ingresso.
Da qui la forma della soluzione: **un endpoint solo, sia per il form che per
TidyCal**, e per TidyCal un pezzo in più che va a prendersi le prenotazioni.
### C1 · Endpoint di ingresso — `POST /api/webhooks/lead`
Un solo endpoint, indipendente da chi chiama.
- **Guardia**: header `x-webhook-secret` confrontato con una env nuova
(`LEAD_WEBHOOK_SECRET`), stesso pattern di
[validate-slug/route.ts:7](src/app/api/internal/validate-slug/route.ts#L7) —
ma **senza** la scorciatoia "se non è configurato passa": questa route è
esposta a internet, secret assente deve voler dire 403.
- **Rate limit** con `rateLimit()` da [rate-limit.ts](src/lib/rate-limit.ts),
bucket per IP. `src/proxy.ts` **non intercetta** `/api/*` (matcher a riga 120),
quindi la difesa è tutta dentro la route.
- **Payload** validato con Zod: `name` obbligatorio, `email`/`phone`/`company`/
`notes` opzionali, più un `source` libero. Mappatura campi in un modulo a
parte così aggiungere una sorgente non tocca la route.
- **Effetto**: crea un `lead` in stato `contacted` riusando le funzioni di
[lead-service.ts](src/lib/lead-service.ts). Se l'email esiste già, aggiorna
`last_contact_date` invece di duplicare.
Questo è tutto ciò che serve per Elementor, ed è pronto in anticipo per Astro:
la decisione Elementor-vs-Astro **non blocca niente**, perché il contratto è
un POST JSON in entrambi i casi.
### C2 · TidyCal — sincronizzazione a polling
Senza webhook, l'unico modo pulito senza terze parti è andare a leggere.
- `src/lib/tidycal.ts` — client con Personal Access Token in
`TIDYCAL_ACCESS_TOKEN`, che chiede le prenotazioni create dall'ultimo giro.
- `GET /api/internal/tidycal-sync` — protetta da `INTERNAL_SECRET`, come le due
internal esistenti. Per ogni prenotazione nuova crea un lead con nome, email e
la data della call in `next_action_date`, e registra un'`activity` di tipo
`meeting`. Idempotente sull'id TidyCal, così rilanciarla non duplica.
- **Schedulazione**: Coolify ha gli Scheduled Tasks. Un `curl` ogni 15 minuti
verso quella route. Non serve infrastruttura cron nell'app — e infatti nel
repo non ce n'è.
**Prima di scrivere il client va guardata la documentazione vera dell'API**, che
è dietro login (`tidycal.com/integrations` → API Keys) e non è indicizzata: forma
esatta dell'endpoint prenotazioni, filtro per data, paginazione. È il primo passo
del blocco, non un dettaglio: vale la regola già scritta in memoria — *ogni
estrazione da API di terzi va vista funzionare, non dedotta dai docs*.
Se l'API risultasse inadatta, il ripiego è Zapier/Make che POSTa su C1, che a
quel punto esiste già.
### C3 · "Alleggerire l'hub"
Questa richiesta non ha ancora un perimetro. Non la pianifico al buio: quando i
blocchi A e B sono in produzione ti porto l'elenco di cosa è davvero poco usato
(`/admin/quotes` e `/admin/preventivi` convivono, `service_catalog` e
`offer_services` sono legacy da DEBT-02) e decidi tu cosa togliere. Cancellare
route è irreversibile e va fatto guardando, non indovinando.
---
## Blocco D — Whop → audit *(rinviato, dipende dal motore)*
L'ingresso Whop è già predisposto nello schema: `audits.origin` accetta
`'whop'` e `audits.external_ref` tiene il riferimento esterno
([schema.ts:688-690](src/db/schema.ts#L688-L690)).
Ma "partono gli agent e fanno il lavoro" oggi **non è possibile**: in
`src/lib/audit/` ci sono solo le cinque fonti; agent, sintetizzatore e pipeline
(AUD-06 → AUD-11) non sono scritti. Costruire il webhook adesso significa
costruire un innesco che non innesca niente.
Quando il motore c'è, il blocco è: `POST /api/webhooks/whop` con verifica firma →
crea cliente + progetto con la Entry Offer assegnata (così l'audit compare
automaticamente in B1 e B2) → crea l'`audit` con `origin='whop'` → accoda la run.
---
## Migration 0018 — additiva
`src/db/migrations/0018_timer_scope_and_due_date.sql`, in parallelo alle stesse
modifiche in [schema.ts](src/db/schema.ts) (drizzle-kit generate è rotto, i due
file si tengono in pari a mano).
```
ALTER TABLE time_entries ADD COLUMN phase_id text REFERENCES phases(id) ON DELETE SET NULL;
ALTER TABLE time_entries ADD COLUMN task_id text REFERENCES tasks(id) ON DELETE SET NULL;
CREATE INDEX time_entries_phase_idx ON time_entries(phase_id);
CREATE INDEX time_entries_task_idx ON time_entries(task_id);
ALTER TABLE projects ADD COLUMN due_date timestamptz;
```
Solo `ADD COLUMN` e `CREATE INDEX`: nessun DROP, nessun TRUNCATE, nessuna colonna
rimossa — conforme a Data Safety (LOCKED). Le righe esistenti di `time_entries`
restano valide con le due colonne a NULL, cioè "tempo di progetto".
**Ordine di applicazione**, come da CLAUDE.md: prima a produzione via
`ssh root@178.104.27.55` + `docker exec`, **poi** il push del codice che le usa.
---
## Ordine di esecuzione
1. **A1** — le due rimozioni (nessuna dipendenza, effetto immediato)
2. **Migration 0018** in produzione
3. **A3** timer su task/fase · **A2** mini-dashboard progetto
4. **B3** inbox (piccola) → **B1** analytics prodotto → **B2** timeline consegne
5. **C1** endpoint lead → **C2** TidyCal, dopo aver letto l'API vera
6. **C3** e **D** dopo, con le informazioni che oggi non abbiamo
Commit per unità logica, come negli ultimi quattro.
## Verifica
Non c'è suite di test in questo progetto: **`npm run build` è la verifica di
riferimento** (fa il typecheck), più `npm run lint`.
Controlli manuali, in ordine:
- **A1** — il progetto non mostra più il tab Commenti; gli stessi commenti si
vedono ancora in `/admin/conversazioni` con la loro etichetta d'entità; la
lista progetti non ha più la colonna Timer.
- **A3** — timer avviato su un task: `time_entries` ha `task_id` valorizzato; il
totale della fase è la somma dei suoi task; il totale progetto non cambia.
Poi cancellare quel task e verificare che **l'entry sopravviva** con
`task_id` a NULL.
- **A2 / B1 / B2** — confrontare ogni numero a video con la stessa query lanciata
a mano su prod via `psql`. Su un dataset di 5 progetti si controllano tutti a
occhio; è l'unico momento in cui questo è ancora possibile.
- **B2** — mettere una `due_date` manuale su un progetto e verificare che vinca
sulla data derivata dall'offerta.
- **C1** — `curl` con secret giusto (lead creato), con secret sbagliato (403),
senza secret (403), e ripetuto per verificare che non duplichi.
- Dual light/dark su ogni schermata nuova, e nessuna classe palette raw.
Il portale cliente non va toccato da nessuna di queste modifiche: `client-view.ts`
non si tocca, quindi il vincolo LOCKED #2 non è in gioco.