Files
iti-technology/CLAUDE.md
T
Lorenzo SanesiandClaude Opus 5.5 b83ce039fb
Deploy stack su Proxmox / deploy (push) Failing after 3s
Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA),
apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/;
infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e
workflow Gitea Actions per il deploy via SSH a ogni push su master.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
2026-10-20 09:30:48 +02:00

1437 lines
210 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# ITI TECHNOLOGY — Piattaforma web
## Panoramica
Portale web per ITI TECHNOLOGY: trattamento acqua, fotovoltaico/energia rinnovabile, comfort (pompe di calore, climatizzazione), mobilità elettrica, soluzioni industriali. Include e-commerce, blog/SEO, configuratore guidato, assistente AI (fase 2), area clienti, area installatori riservata, pannello admin.
Due applicazioni separate, un solo backend custom, un motore e-commerce dedicato.
## Architettura
```
iti-technology/
├── apps/
│ ├── public/ → Angular SSR — sito pubblico (home, soluzioni, blog, shop, configuratore, contatti)
│ ├── private/ → Angular SPA — area clienti / area installatori / admin
│ └── api/ → Fastify — backend custom, unico gateway verso Medusa
├── packages/
│ ├── shared/ → @iti/shared — tipi e DTO condivisi (TypeScript)
│ └── styles/ → SCSS condiviso da public e private (token, mixin, base): solo file, nessun package.json
├── medusa/ → progetto standalone (create-medusa-app) — motore e-commerce; backend in medusa/apps/backend
├── prompts/ → prompt di implementazione (vedi "Stato implementazione")
├── infra/postgres/ → Dockerfile Postgres (PostGIS + pgvector) + script init
├── infra/caddy/ → Dockerfile (Caddy + build di apps/private), Caddyfile, sites/ per siti aggiuntivi
├── infra/backup/ → backup.sh / restore.sh (girano nei container "backup" e "restore")
├── docker-compose.dev.yml → infrastruttura locale (Postgres, Redis)
├── docker-compose.prod.yml → produzione (vedi "Deploy")
├── .env.prod.example → variabili di produzione (sulla VPS diventa `.env`)
├── tsconfig.base.json
├── SECURITY_REVIEW.md → review di sicurezza pre go-live (prompt 34): esito di ogni voce e punti aperti
└── CLAUDE.md
```
### Workspace npm
- Root `package.json` (`iti-technology`, private) con `workspaces: ["apps/*", "packages/*"]`. **`medusa/` è escluso**: ha il suo `package.json` e `node_modules`, si installa a parte.
- Node 24 LTS (`.nvmrc`, `engines.node >= 24`), npm 11. TypeScript **`~6.0`** come devDependency di root (condiviso dai workspace). Portato da 5.9 a 6.0 nel prompt 22 perché Angular 22 richiede `>=6.0 <6.1`: `apps/api` e `packages/shared` compilano senza modifiche (typecheck, build e 207 test verificati).
- `tsconfig.base.json`: strict + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`; target ES2022, `module`/`moduleResolution` `NodeNext`. Ogni package la estende (le app Angular potranno sovrascrivere `module`/`moduleResolution` con `bundler`).
- Script root: `npm run build` (tutti i workspace con script `build`), `npm run build:shared`.
- Finché un'app non ha un `package.json`, la cartella `apps/<nome>/` contiene solo `.gitkeep` e npm la ignora.
### `packages/styles` (SCSS condiviso)
- `_tokens.scss` (colori, font, spaziature, raggi, ombre, breakpoint), `_mixins.scss` (`mq.up`, `focus-ring`, `visually-hidden`), `_base.scss` (custom properties su `:root`, reset, tipografia, `.container`, `.btn`, form: `.form-grid`, `.form-field`, `fieldset`, `.check`, `.alert`), `_prose.scss` (mixin `prose.container` e `prose.content`: tipografia del corpo degli articoli del blog, usata dalla pagina articolo del sito **e** dall'anteprima dell'editor admin, così l'anteprima è identica al risultato; prompt 32). Spostati qui da `apps/public/src/styles` e `styles.scss` nel prompt 29.
- Le due app hanno `stylePreprocessorOptions.includePaths: ["../../packages/styles"]` in `angular.json`: nei componenti basta `@use 'tokens'` / `@use 'mixins' as mq`, e lo `styles.scss` di ogni app fa `@use 'base';` più le sue aggiunte.
- Non è un workspace npm (niente `package.json`): è una cartella letta da Sass. Una modifica qui vale per **entrambe** le app.
### `packages/shared` (`@iti/shared`)
- ESM (`"type": "module"`), compilato con `tsc` in `dist/` (JS + `.d.ts`), entry `exports["."]` → `dist/index.js`. Import relativi interni con estensione `.js` (richiesto da NodeNext).
- Va buildato prima di usarlo dalle app: lo fanno in automatico `postinstall` di root e `prebuild` di `apps/api`; dopo modifiche manuali `npm run build:shared`.
- Contiene `UserRole`/`USER_ROLES`, le costanti degli stati di dominio e i DTO pubblici (dettaglio nella sezione "Schema dati custom"). Qui vanno tipi, costanti e DTO condivisi tra `api`, `public` e `private`: niente logica runtime con dipendenze.
- Consumo dalle app: dipendenza `"@iti/shared": "*"` nel `package.json` dell'app (npm la risolve via symlink al workspace).
### Infrastruttura di sviluppo (`docker-compose.dev.yml`)
Progetto compose `iti-technology-dev`, variabili lette da `.env` in root (copiare `.env.example`; il compose fallisce se mancano).
| Comando | Effetto |
|---|---|
| `npm run infra:up` | build immagine Postgres + avvio, attende i healthcheck (`--wait`) |
| `npm run infra:down` | ferma e rimuove i container (i volumi restano) |
| `npm run infra:logs` | log in follow |
| `docker compose -f docker-compose.dev.yml down -v` | reset completo: cancella i volumi, al prossimo `up` rigira l'init |
- **postgres** (porta `POSTGRES_PORT`, default 5432): immagine custom `infra/postgres/Dockerfile` = `postgis/postgis:16-3.4` + `postgresql-16-pgvector` (0.8.x). Volume `postgres-data`.
- L'immagine base è su Debian bullseye: il repo PGDG originale risponde 404, quindi il Dockerfile punta a `apt-archive.postgresql.org` (http, pacchetti firmati). Le immagini `postgis/postgis:16-*` sono tutte bullseye; valutare in futuro un'immagine bookworm.
- Init `infra/postgres/init/01-init.sql` (solo a volume vuoto): legge le credenziali con `\getenv` e crea i ruoli `APP_DB_USER` / `MEDUSA_DB_USER`, i database `APP_DB_NAME` (`iti_app`), `<APP_DB_NAME>_test` (`iti_app_test`, per i test automatici di `apps/api`) e `MEDUSA_DB_NAME` (`iti_medusa`) ciascuno di proprietà del proprio utente, `CONNECT` revocato a `PUBLIC` (un utente non accede al DB dell'altro). In `iti_app` e `iti_app_test` crea le estensioni `postgis`, `vector`, `pg_trgm`, `unaccent`.
- Healthcheck `pg_isready -h 127.0.0.1`: diventa healthy solo a init completato.
- **redis** (`redis:7-alpine`, porta `REDIS_PORT`, default 6379, AOF attivo): volume `redis-data`, healthcheck `redis-cli ping`. URL in `REDIS_URL`.
- Variabili: `POSTGRES_USER/PASSWORD/DB/PORT`, `APP_DB_NAME/USER/PASSWORD`, `MEDUSA_DB_NAME/USER/PASSWORD`, `REDIS_PORT`, `REDIS_URL`.
### Medusa (`medusa/`)
Medusa **2.21.2**, creato con `create-medusa-app` (senza storefront, npm). Il template 2.21 è un piccolo monorepo **turbo**: root `medusa/package.json` (workspaces `apps/**`) e il backend vero e proprio in **`medusa/apps/backend`** (package `@dtc/backend`). Contiene il proprio `CLAUDE.md`/`AGENTS.md` del template con le convenzioni Medusa: leggerli quando si lavora lì dentro.
**Avvio (dev)** — con l'infrastruttura su (`npm run infra:up` in root):
```powershell
cd medusa
npm install # solo la prima volta (progetto separato dai workspace di root)
npm run dev # turbo dev → medusa develop su http://localhost:9000
```
- Dashboard admin: **http://localhost:9000/app** · health: `GET http://localhost:9000/health` → 200 `OK`.
- Comandi CLI da lanciare **in `medusa/apps/backend`**: `npx medusa db:migrate` (migrazioni + indici search), `npx medusa user -e <email> -p <password>` (crea un utente admin del dashboard).
- Utente admin di sviluppo: `admin@iti.local` (password comunicata in chat, non salvata nel repo; se persa ricreare un utente con il comando sopra).
**Configurazione** (`medusa/apps/backend/medusa-config.ts`, env in `medusa/apps/backend/.env`, modello in `.env.example`):
- `DATABASE_URL` → database `iti_medusa` con utente `iti_medusa` (credenziali `MEDUSA_DB_*` del `.env` di root).
- `REDIS_URL` usato da `projectConfig.redisUrl` e dai moduli Redis consigliati dalla guida di deployment: `caching` (provider `caching-redis`), `event-bus-redis`, `workflow-engine-redis`, `locking` (provider `locking-redis`). Tutti sullo stesso Redis.
- **In produzione** (`NODE_ENV=production`) la config rifiuta di partire se `JWT_SECRET`, `COOKIE_SECRET`, `INTERNAL_API_SECRET` o `WEBHOOK_SECRET` mancano o hanno meno di 32 caratteri (senza `JWT_SECRET` Medusa userebbe `supersecret`). Avvisa se c'è `STRIPE_API_KEY` senza `STRIPE_WEBHOOK_SECRET`.
- CORS da env: `STORE_CORS`, `ADMIN_CORS`, `AUTH_CORS`. Il frontend non chiama Medusa (passa da `apps/api`), quindi `STORE_CORS` resta minimale.
- Segreti: `JWT_SECRET`, `COOKIE_SECRET`, `AUTH_MFA_ENCRYPTION_KEY` (generati casuali nel `.env` locale).
- File: modulo `@medusajs/medusa/file` con provider `file-local`; cartella `FILE_UPLOAD_DIR` (default `static`, relativa al backend) e URL pubblico `FILE_BACKEND_URL` (default `http://localhost:9000/static`). Medusa serve **sempre e solo** `<backend>/static` su `/static`: se in produzione `FILE_UPLOAD_DIR` punta a un volume diverso, quel volume va servito da Caddy e `FILE_BACKEND_URL` aggiornato.
**Modifiche al template**:
- Rimossi `src/migration-scripts/initial-data-seed.ts` (veniva eseguito da `db:migrate` e creava regione "Europe", USD, spedizioni e prodotti demo) e `src/scripts/seed-demo-products.ts`; rimossi gli script `backend:seed` e `storefront:dev`. Il database `iti_medusa` è stato ricreato pulito. La configurazione di store/regione si fa nel prompt 04.
- Tenuto `src/search/` (indice prodotti del modulo search di Medusa, su Postgres); `PRICE_CURRENCIES` in `src/search/helpers/pricing.ts` ridotto a `["eur"]`.
- `medusa/package.json` impostato `private: true`; `overrides.lodash = ^4.18.1` (prompt 34: le versioni ≤ 4.17.23 hanno prototype pollution; Medusa verificata con 4.18.1); rimosso `.npmrc` (conteneva un'opzione solo pnpm che faceva emettere warning a npm).
**Configurazione vendita Italia — `src/scripts/seed-base.ts`** (idempotente, rieseguibile: cerca ogni entità per nome/handle e crea solo ciò che manca):
```powershell
cd medusa/apps/backend
npx medusa exec ./src/scripts/seed-base.ts # stampa MEDUSA_PUBLISHABLE_KEY=pk_...
```
Crea/allinea:
- store "ITI TECHNOLOGY": unica valuta **EUR, prezzi IVA inclusa** (`is_tax_inclusive`), default su sales channel Web / regione Italia / Magazzino ITI;
- regione **"Italia"** (paese `it`, EUR, `automatic_taxes`, IVA inclusa) con payment provider `pp_stripe_stripe`;
- tax region `it` (provider `tp_system`) con aliquota di default **IVA 22%** (codice `IVA22`, provvisoria);
- sales channel **"Web"** + publishable API key **"Web (apps/api)"** collegata → va in `apps/api/.env` come `MEDUSA_PUBLISHABLE_KEY`;
- stock location "Magazzino ITI" (provider fulfillment `manual_manual`), fulfillment set con service zone Italia, shipping option **"Spedizione standard"** flat **15 €** (placeholder, IVA inclusa);
- 3 prodotti di esempio pubblicati sul canale Web, una variante "Standard" ciascuno, giacenza 20: addolcitore 1.190 €, kit FV 6 kW 9.900 €, pompa di calore 8 kW 5.490 €.
- Medusa crea da sé al primo avvio anche "Default Sales Channel" e "Default Publishable API Key": non usati, lasciati lì.
- Importi Medusa v2 in **unità intere** (euro, non centesimi).
- Verificato via Store API: la regione espone `pp_stripe_stripe`; carrello con pompa di calore 5.490 € → `item_tax_total` 990 (IVA scorporata 22/122), spedizione 15 € con IVA, `total` 5.505.
**Stripe**:
- Provider `@medusajs/medusa/payment-stripe`, id `stripe` → provider ID **`pp_stripe_stripe`**. Opzioni: `apiKey` = `STRIPE_API_KEY`, `webhookSecret` = `STRIPE_WEBHOOK_SECRET`, `capture: true` (cattura automatica all'autorizzazione).
- Il provider va in errore all'avvio senza `apiKey`, quindi `medusa-config.ts` lo registra **solo se `STRIPE_API_KEY` è impostata** (altrimenti warning); `seed-base.ts` si ferma se il provider manca.
- **Webhook da registrare nella dashboard Stripe** (Developers → Webhooks): **`https://api.<dominio>/hooks/payment/stripe_stripe`** (in produzione Caddy inoltra a Medusa solo questo percorso di `api.<dominio>`), con gli eventi `payment_intent.amount_capturable_updated`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `payment_intent.partially_funded`. Il signing secret (`whsec_...`) va in `STRIPE_WEBHOOK_SECRET`. In locale si può usare `stripe listen --forward-to localhost:9000/hooks/payment/stripe_stripe`.
- ⚠️ Nel `.env` locale `STRIPE_API_KEY` è un **segnaposto** (`sk_test_PLACEHOLDER_SOSTITUIRE`): il provider risulta nella regione ma creare una sessione di pagamento fallisce. Inserire la chiave di test reale prima del prompt 26 (checkout).
**Listino installatori — `src/scripts/seed-installatori.ts`** (idempotente; richiede `seed-base.ts` già eseguito):
```powershell
cd medusa/apps/backend
npx medusa exec ./src/scripts/seed-installatori.ts # stampa id gruppo e price list
```
- Customer group **`installatori`**, `metadata = { "code": "installer" }`. Id in dev: `cusgroup_01M3YXVSN2NHXNWAZ2CZQTCKS6`. L'id **cambia tra ambienti**: `apps/api` deve trovarlo per codice (gruppo con `metadata.code = "installer"`, oppure per nome `installatori`) o leggerlo da env, mai hardcodarlo.
- Price list **"Listino installatori"**: `type: override`, `status: active`, regola `customer.groups.id = [id gruppo]`. Prezzi EUR IVA inclusa: addolcitore 990, kit FV 8.400, pompa di calore 4.650. Lo script aggiunge solo i prezzi mancanti, non sovrascrive quelli modificati dal dashboard; riallinea stato e regola.
- Nota tecnica: il tipo TS di `createPriceListsWorkflow` non espone `type`, ma lo step lo inoltra al Pricing Module (come `POST /admin/price-lists`); senza, il default sarebbe `sale`.
- Verificato via Store API (stesso carrello con la pompa di calore): guest → 5.490, cliente normale → 5.490, cliente nel gruppo → **4.650**.
**Come aggiungere/rimuovere un customer dal gruppo** (servirà ad `apps/api` all'approvazione di un installatore, prompt 20):
- Admin API: `POST /admin/customer-groups/{groupId}/customers` con body `{ "add": ["cus_..."] }` (o `{ "remove": [...] }`), autenticata come utente admin Medusa (`Authorization: Bearer <token>` da `POST /auth/user/emailpass`, oppure secret API key).
- Dentro Medusa (workflow/route custom, prompt 06): `linkCustomersToCustomerGroupWorkflow(container).run({ input: { id: groupId, add: [customerId] } })`.
- Il prezzo del gruppo si applica al carrello del customer. Un carrello già associato al customer **prima** dell'approvazione viene riprezzato solo alla successiva modifica delle righe (o a un nuovo `transferCartCustomer`); quelli associati dopo (login, `POST /internal/carts/:id/customer`) sono riprezzati subito.
**Route interne `/internal/*`** (chiamabili solo da `apps/api`):
- Protezione: middleware `src/utils/internal-secret.ts` registrato in `src/api/middlewares.ts` su `/internal*`. Header **`x-internal-secret`** = **`INTERNAL_API_SECRET`** (stesso valore in `medusa/apps/backend/.env` e `apps/api/.env`). Confronto con `timingSafeEqual` sui digest SHA-256 (nessuna fuga di contenuto o lunghezza). Segreto mancante o errato, oppure env non impostata → **401** `{"type":"unauthorized"}`.
- Body validati con zod (`src/api/internal/validators.ts`, `validateAndTransformBody`) → 400 `invalid_data`. Le email vengono normalizzate con **trim + minuscolo**: `apps/api` deve usare la stessa normalizzazione.
- Errori Medusa standard: 404 `not_found` per id inesistenti.
| Route | Body / query | Implementazione | Risposta |
|---|---|---|---|
| `POST /internal/customers` | `{ email, first_name, last_name, company_name? }` | workflow custom `upsertAccountCustomerWorkflow` → cerca per email con `has_account=true`, altrimenti **`createCustomersWorkflow`** (con `has_account: true`) | `201 { customer, created: true }` / `200 { customer, created: false }` |
| `POST /internal/customers/:id/groups` | `{ action: "add" \| "remove" }` | workflow custom `setInstallerGroupWorkflow` → trova il gruppo per `metadata.code = "installer"`, poi **`linkCustomersToCustomerGroupWorkflow`** | `{ customer_id, group: { id, name }, member }` |
| `POST /internal/carts/:id/customer` | `{ customer_id }` | **`transferCartCustomerWorkflow`** (aggiorna customer ed email del carrello e **riprezza** le righe → listino installatori) | `{ cart }` (id, email, customer_id, total, items) |
| `POST /internal/carts/:id/guest` | `{ email }` | workflow custom `setCartGuestEmailWorkflow` → customer **guest** (`has_account=false`) per l'email (trovato o creato) + **`transferCartCustomerWorkflow`**; `not_allowed` se il carrello è già di un account | `{ cart_id, email }` |
| `POST /internal/prices` | `{ customer_id, region_id, variant_ids[] }` (max 200) | `query.graph` delle varianti con `calculated_price` nel contesto `{ region_id, currency_code, customer.groups }` del customer (come la Store API con customer autenticato) | `{ prices: { [variant_id]: { amount, original_amount, currency_code, price_list_type } } }` |
| `POST /internal/orders/claim` | `{ customer_id, email }` | per ogni ordine con quella email (ILIKE letterale, case-insensitive) il cui customer è guest e non annullato: workflow custom `claimGuestOrderWorkflow` = **`requestOrderTransferWorkflow`** + lettura token + **`acceptOrderTransferWorkflow`** | `{ claimed: [order_id], failed: [{ order_id, message }] }` |
| `GET /internal/orders/:id` | — | **`getOrderDetailWorkflow`** (niente draft) con righe (anche `thumbnail`), totali, indirizzo di spedizione, metodi di spedizione, fulfillment con `labels` (tracking) | `{ order }` / 404 |
| `GET /internal/customers/:id/orders` | `?limit=20&offset=0` (max 100) `[&display_id=N]` | **`getOrdersListWorkflow`** (filtro `customer_id`, niente draft, più recenti prima; con `display_id` solo quell'ordine, se è del customer: usato da `GET /me/orders/:number`) | `{ orders, count, offset, limit }` con `payment_status`, `fulfillment_status`, totali, righe |
Workflow custom in `src/workflows/internal/`. Note:
- **Email del checkout guest — perché `/internal/carts/:id/guest`**: con la Store API, impostare l'email su un carrello senza customer (`POST /store/carts/:id { email }`, o `email` alla creazione) passa da `findOrCreateCustomerStep`, che cerca per email **senza filtrare `has_account`**. Se esiste un account registrato con quell'email, il carrello viene associato a quell'account con i suoi prezzi di gruppo: chiunque digiti l'email di un installatore otterrebbe il listino riservato, e l'ordine finirebbe nello storico dell'account. Per questo `apps/api` **non passa mai `email` alla Store API** e per i guest usa `/internal/carts/:id/guest`. Un carrello che ha già un customer guest può cambiare email senza problemi: resta guest.
- **Listini `type: override`**: nel `calculated_price` Medusa riporta come `original_amount` il prezzo del listino stesso, non quello pubblico. Il prezzo pubblico è il `calculated_price` calcolato senza contesto customer.
- **Customer account vs guest**: Medusa ammette un customer per coppia `(email, has_account)`. Il checkout guest crea customer con `has_account=false`; quelli creati da Fastify hanno `has_account=true`. Questo è **necessario**: `requestOrderTransferWorkflow` rifiuta il trasferimento verso customer guest.
- **Claim**: il flusso ufficiale prevede che il token arrivi al cliente via email. Qui viene letto e accettato internamente, quindi **`apps/api` deve chiamare il claim solo dopo aver verificato il possesso dell'email** (es. email verificata alla registrazione). Il claim richiede che il customer abbia la stessa email del body. Rieseguirlo non ha effetti (nessun ordine guest residuo).
- **Test manuali**: `medusa/apps/backend/http/internal.http` (HTTP Client WebStorm, ambiente `dev`). Le variabili pubbliche stanno in `http-client.env.json`, i segreti in `http-client.private.env.json` (gitignored; modello in `http-client.private.env.example.json`). L'ordine guest di test si crea come draft order via Admin API e poi si converte, così non serve un pagamento.
- **Verificato** (stesso scenario via curl): 401 su tutte e 5 le route senza segreto o con segreto errato; customer 201 → 200 con stesso id; carrello guest 5.490 → dopo gruppo e associazione **4.650**; claim dell'ordine guest (email con maiuscole) → `claimed`, ripetuto → vuoto; elenco ordini con l'ordine collegato; customer inesistente 404; body invalido 400.
**Webhook Medusa → Fastify** (`src/subscribers/fastify-order-webhooks.ts` + `src/utils/fastify-webhook.ts`):
- Eventi (nomi verificati in `OrderWorkflowEvents` di Medusa 2.21):
| Evento | Emesso da | `data` originale |
|---|---|---|
| `order.placed` | `completeCartWorkflow` (checkout), conversione draft order | `{ id }` |
| `order.canceled` | `cancelOrderWorkflow` | `{ id }` |
| `order.fulfillment_created` | `createOrderFulfillmentWorkflow` | `{ order_id, fulfillment_id, no_notification }` |
- Destinazione: `POST {FASTIFY_WEBHOOK_URL}/webhooks/medusa`, `Content-Type: application/json`.
- **Payload**:
```json
{
"id": "2f95bee9-bac2-52d3-848a-52abba09eeb7",
"event": "order.placed",
"occurred_at": "2026-10-02T18:43:21.765Z",
"data": {
"id": "order_01...",
"order": { "id": "order_01...", "display_id": 2, "status": "pending", "email": "cliente@example.com",
"customer_id": "cus_01...", "currency_code": "eur", "total": 1190, "created_at": "..." }
}
}
```
`data` = dati originali dell'evento + `order` (snapshot letto al momento dell'evento; `null` se non trovato). `occurred_at` = istante in cui il subscriber elabora l'evento.
- **`id` evento**: UUID v5 **deterministico** da `nome evento + data originale`. Lo stesso evento ha sempre lo stesso id, anche se il subscriber viene rieseguito, ed è identico in tutti i retry. `apps/api` deduplica su questo id (`webhook_events`, prompt 14).
- **Firma**: header **`x-signature`** = HMAC-SHA256 in **esadecimale minuscolo** del **body grezzo** (i byte esatti inviati) con chiave **`WEBHOOK_SECRET`** (stesso valore in `medusa/apps/backend/.env` e `apps/api/.env`). Lato Fastify: calcolare l'HMAC sul raw body **prima** del parse JSON e confrontare con `timingSafeEqual`. Esempio di riferimento in `medusa/apps/backend/http/webhook-receiver.mjs`.
- **Retry**: fino a **5 tentativi** se la risposta non è 2xx o c'è un errore di rete (timeout 5 s per tentativo), con backoff esponenziale 2 s, 4 s, 8 s, 16 s (base configurabile con `WEBHOOK_RETRY_BASE_MS`). Ogni tentativo fallito è un `warn` nei log, l'abbandono è un `error` con il payload completo (per reinvio manuale).
- **Mai bloccante**: gli eventi arrivano dopo il commit dell'ordine; il subscriber prepara il payload e avvia la consegna **senza await**. Il worker dell'event bus elabora un job alla volta: con l'await i retry di un webhook bloccavano tutti gli altri subscriber, Medusa inclusa (verificato).
- **Limite noto**: i retry vivono in memoria. Se Medusa si riavvia durante il backoff, l'invio si perde (resta il log). Se servirà garanzia di consegna: outbox su DB oppure job di riconciliazione.
- Senza `FASTIFY_WEBHOOK_URL` o `WEBHOOK_SECRET` l'invio viene saltato con un warning.
- **Ricevitore**: `POST /webhooks/medusa` di `apps/api` (prompt 14, vedi sezione `apps/api`). **Receiver di test** standalone, utile per provare il subscriber senza l'API: `node http/webhook-receiver.mjs 3000` in `medusa/apps/backend`. Verifica la firma e stampa le richieste; con `FAIL=n` risponde 503 alle prime n.
- **Verificato**: ordine (draft convertito) → `order.placed` firmato; con `FAIL=2` → 503, 503, 200 al 3° tentativo (backoff 2 s, 4 s); `order.fulfillment_created` e `order.canceled` consegnati e firmati. Con l'endpoint giù l'ordine si completa in ~0,6 s e i 5 tentativi di `order.placed` e `order.fulfillment_created` compaiono nei log in parallelo, fino all'abbandono.
- ⚠️ In sviluppo, prima di riavviare Medusa assicurarsi che le istanze precedenti di `turbo dev`/`medusa develop` siano chiuse: chiudere solo il processo sulla porta 9000 lascia vivi i worker, che continuano a consumare gli eventi dalla coda Redis condivisa.
**Decisione — prezzi IVA inclusa**: i prezzi in EUR per la regione Italia sono inseriti e mostrati **IVA inclusa** (vendita B2C); Medusa scorpora l'IVA nei totali. Vale anche per i listini (es. installatori, prompt 05), salvo diversa decisione.
**Decisione — catalogo nell'MVP**: prodotti, categorie, prezzi, listini e ordini si gestiscono dal **dashboard admin integrato di Medusa** (`/app`). Il pannello admin custom (`apps/private`, prompt 32) non duplica la gestione catalogo (copre preventivi, ticket, installatori, utenti, blog, documenti, contatti/showroom) e mette un link diretto al dashboard Medusa. In produzione il dashboard è su `shop-admin.{DOMAIN}`, protetto da whitelist IP **e** basic auth (vedi "Deploy").
**Perché due app**: `public` ha bisogno di SSR per la SEO (blog, pagine soluzioni); `private` è dietro login, SPA è sufficiente, nessun bisogno di SSR.
**Perché Medusa resta a parte**: ha il suo scaffolding e versioning proprio, si tratta come servizio esterno consumato via API, non si modifica la sua struttura interna.
## Stack tecnico
| Layer | Scelta |
|---|---|
| Frontend pubblico | Angular (standalone, signals, zoneless, SCSS) + SSR |
| Frontend privato | Angular (standalone, signals, zoneless, SCSS) SPA |
| Backend custom | Fastify / Node.js |
| Database app | PostgreSQL (+ pgvector per RAG futuro) |
| Geolocalizzazione | PostGIS (assegnazione installatori per zona) |
| E-commerce | Medusa (Node/TS), proprio Postgres separato |
| Pagamenti | Stripe (plugin ufficiale Medusa) |
| Storage media | Disco VPS, volume Docker + Caddy (no MinIO per ora) |
| Reverse proxy / TLS | Caddy, sottodomini sullo stesso dominio |
| Email transazionali | Provider esterno (da scegliere: Brevo o Resend) |
| Ricerca | `tsvector` Postgres (full-text nativo) |
| Hosting | VPS esistente, Docker Compose |
| Automazioni | n8n (istanza già esistente sulla VPS) |
## Decisioni architetturali chiave
**Autenticazione — login unico**
`apps/api` (Fastify) è l'unico gateway di autenticazione. Il frontend non parla mai direttamente con Medusa.
1. Login/registrazione → sempre verso Fastify, JWT proprio
2. Fastify crea prima la riga in `users` (Postgres), poi il customer Medusa corrispondente via Admin API, mappato con `users.medusa_customer_id`
3. Operazioni shop (carrello/checkout) → Fastify fa da proxy verso Medusa usando quella mappatura
4. Un solo login, un solo JWT, zero password duplicate
**Sync Medusa → Fastify**
Subscriber scritto dentro il progetto Medusa (reagisce a `order.placed`, `order.completed`, ecc.) che chiama `/webhooks/medusa` su Fastify via HTTP.
**Checkout**
Guest checkout abilitato (solo email, nessun account obbligatorio). Al momento della registrazione, Fastify cerca ordini Medusa passati con la stessa email e li collega al nuovo `user_id`.
**Listino riservato installatori**
Customer groups + price lists in Medusa — nessuna logica di pricing custom da scrivere, è nativo.
**IVA**
Aliquota flat 22% provvisoria per non bloccare il checkout. Prezzi inseriti e mostrati IVA inclusa (vedi sezione Medusa). Aliquote agevolate e fatturazione elettronica da affinare dopo il lancio.
**Configuratore**
MVP: wizard a domande guidate con logica a regole (branching + tabelle di soglie in Fastify), **nessuna AI**. Output predicibile, zero costi/latenza OpenAI, genera comunque una `quote_request`.
**Assistente AI — STEP 2, non nell'MVP**
Quando verrà implementato: RAG con pgvector. Schede tecniche → chunk → embedding OpenAI → salvati in Postgres. Domanda utente → embedding → similarity search → chunk rilevanti nel prompt → risposta GPT ancorata ai documenti reali (niente invenzioni). Serve sia per l'assistente conversazionale che per arricchire il configuratore in futuro. Attenzione GDPR: richiede DPA con OpenAI (dati utente verso processor extra-UE).
**Editor blog**
Custom, markdown semplice: textarea + preview live nell'admin, salvato come testo in `blog_posts.content`, convertito in HTML a runtime (`marked`/`remark`) lato Fastify. Niente rich-text/WYSIWYG, niente dipendenze SaaS terze.
**Monitoring**
Per ora log su file/console. Nessun Sentry o simile per il momento.
## Schema dati custom (Postgres — apps/api)
Database `iti_app`, schema Drizzle in `apps/api/src/db/schema/` (`users.ts` identità, `domain.ts` dominio, `columns.ts` tipi custom). Prodotti, ordini e carrelli **non sono in questo schema**: vivono nel Postgres di Medusa e si collegano tramite `users.medusa_customer_id`.
**ERD sintetico** (PK = chiave primaria, FK → tabella [azione on delete]; `?` = nullable; tutte le tabelle hanno `created_at`, quasi tutte `updated_at`):
```
users id PK · email (minuscola, unique lower()) · password_hash · role user_role
· first_name · last_name · company_name? · vat_number? · medusa_customer_id? unique
· email_verified_at? · disabled_at?
├─< refresh_tokens user_id → users [cascade] · token_hash unique · family_id · expires_at · revoked_at? · user_agent? · ip? inet
├─< password_reset_tokens user_id → users [cascade] · token_hash unique · expires_at · used_at?
├─< email_verification_tokens (stessa struttura di password_reset_tokens)
├─1 installer_profiles user_id PK → users [cascade] · business_name · vat_number? · phone? · address?
│ · location? geography(Point,4326) [GIST] · service_radius_km (>0, default 50)
│ · certifications text[] · status installer_status · approved_at? · notes? (solo admin)
├─< quote_requests id · user_id? → users [set null] (anonime ammesse) · email (minuscola) · name · phone? · province?
│ │ · configurator_input jsonb · configurator_output? jsonb · status quote_request_status
│ └─< quotes id · quote_request_id? → quote_requests [set null] · user_id → users [restrict]
│ · year · seq (unique year+seq) · number unique "2026-0001" · title · status quote_status
│ · amount_cents bigint (≥0) · pdf_document_id? → documents [set null] · valid_until? date · sent_at?
├─< tickets id · user_id → users [cascade] · subject · status ticket_status · priority ticket_priority
│ └─< ticket_messages id · ticket_id → tickets [cascade] · author_id → users [restrict] · body
├─< blog_posts (author) author_id? → users [set null]
├─< contact_requests id · user_id? → users [set null] · name · email · phone? · subject? · message · status contact_request_status
├─< showroom_bookings id · user_id? → users [set null] · name · email · phone? · requested_at (inizio slot) · people (>0) · notes? · status showroom_booking_status
└─< consents id · user_id? → users [cascade] · email? (almeno uno dei due) · type consent_type · version · source?
· accepted_at · revoked_at? · ip? inet
blog_categories id · slug unique · name · description?
└─< blog_posts id · slug unique · title · excerpt? · content_md · cover_path? · status blog_post_status · published_at?
· seo_title? · seo_description? · category_id? → blog_categories [set null]
· search_vector tsvector GENERATED [GIN]
documents id · title · category? · file_path (relativo all'area private/) · mime_type · size_bytes bigint
· visibility document_visibility · product_ref? (handle Medusa)
webhook_events id uuid PK (= id evento Medusa, idempotenza) · event · payload jsonb · received_at · processed_at? · error?
```
**Enum Postgres** (valori da `@iti/shared`, unica fonte): `user_role` (customer, company, installer, admin) · `installer_status` (pending, approved, rejected) · `quote_request_status` (new, contacted, converted, closed) · `quote_status` (draft, sent, accepted, rejected, expired) · `ticket_status` (open, in_progress, waiting_customer, closed) · `ticket_priority` (low, normal, high, urgent) · `blog_post_status` (draft, published) · `document_visibility` (public, customer, installer, admin) · `contact_request_status` (new, in_progress, closed) · `showroom_booking_status` (requested, confirmed, cancelled, completed) · `consent_type` (privacy, marketing).
**Note di schema**:
- **Email minuscole** ovunque (check `*_email_lowercase`): il codice deve normalizzare con trim + `toLowerCase()` prima di scrivere e cercare.
- **Full-text blog**: `search_vector` = `setweight(to_tsvector('italian', immutable_unaccent(title)),'A') || …(excerpt,'B') || …(content_md,'C')`, colonna generata `STORED` con indice GIN. Query: `search_vector @@ websearch_to_tsquery('italian', unaccent($q))`, ordinamento con `ts_rank`. `immutable_unaccent(text)` è un wrapper IMMUTABLE di `unaccent` (le colonne generate non accettano funzioni STABLE), creato dalla migrazione custom `0001_immutable_unaccent.sql`.
- **Geografia**: custom type `geographyPoint` (`columns.ts`). In TypeScript è `{ lat, lng }` (`GeoPoint` di shared): in scrittura diventa `ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography`, in lettura viene convertito da EWKB. Per i calcoli usare la colonna in `sql\`…\``: `ST_DWithin(location, punto, service_radius_km * 1000)` e `ST_Distance` (metri).
- **Numero preventivo**: `year` + `seq` (unique) e `number` = `${year}-${seq a 4 cifre}`; la generazione (prompt 19) prende `max(seq)+1` dell'anno e riprova sul conflitto unique.
- Migrazione `0003_quotes_sent_at`: `quotes.sent_at` (primo invio al cliente).
- ⚠️ **drizzle-kit e `geography`**: drizzle-kit genera `"geography(Point,4326)"` tra virgolette (tipo inesistente). In `0002_domain.sql` è stato corretto a mano; se una futura migrazione tocca la colonna `location`, correggere di nuovo il SQL generato prima di applicarlo.
- **Tipi riga** esportati da `apps/api/src/db/schema` (`User`, `InstallerProfile`, `Quote`, `BlogPost`, `NewBlogPost`, …: `$inferSelect` / `$inferInsert`).
**`packages/shared`** (`@iti/shared`):
- `domain.ts`: costanti degli stati (`INSTALLER_STATUSES`, `QUOTE_STATUSES`, …) e relativi tipi; `user-role.ts`: `USER_ROLES` / `UserRole`.
- `dto.ts`: **DTO pubblici** delle risposte API (`UserDto`, `InstallerProfileDto`, `QuoteRequestDto`, `QuoteDto`, `TicketDto`, `TicketMessageDto`, `BlogCategoryDto`, `BlogPostSummaryDto`, `BlogPostDetailDto`, `BlogPostAdminDto`, `DocumentDto`, `ShowroomBookingDto`, `ShowroomBookingAdminDto`, `ShowroomSlotsDto`, `ContactRequestDto`, `FormTokenDto`, `ConsentDto`, `UserAdminDto`, `AdminSummaryDto`, `GeoPoint`, `Paginated<T>`). Campi camelCase, date ISO string, importi in centesimi; mai campi interni (hash, `file_path`, `notes` admin, payload). Le route dei prompt successivi possono rifinirli.
- Ora contiene anche codice runtime (le costanti), quindi `apps/api` lo importa da `dist/`: viene compilato da `postinstall` di root (dopo ogni `npm install`) e da `prebuild` di `apps/api`. **Dopo una modifica a `packages/shared` lanciare `npm run build:shared`** (oppure `npx tsc -p packages/shared -w` mentre si sviluppa).
## `apps/public` (Angular SSR)
Package `@iti/public`: **Angular 22.2** (ultima stabile al 2026-10), standalone, signals, **zoneless** (default da Angular 21), SCSS, `@angular/ssr` con server Express. Generato con `ng new` (`--ssr --zoneless --style scss`), poi adattato al monorepo.
```powershell
npm run content -w apps/public # rigenera l'indice dei contenuti (parte da solo prima di dev/build/test/typecheck)
npm run dev -w apps/public # ng serve → http://localhost:4200 (SSR anche in sviluppo, usa src/server.ts)
npm run build -w apps/public # build browser + server in apps/public/dist/public
npm run start -w apps/public # node dist/public/server/server.mjs → http://localhost:4000
npm run test -w apps/public # ng test (vitest + jsdom)
npm run typecheck -w apps/public
```
**Struttura**:
```
apps/public/
├── public/ → statici: logo.svg, favicon.svg/.ico, og-default.png, images/aree/<area>.svg (tutti segnaposto)
├── scripts/content-index.mjs → genera src/content/generated/ (indice dei file di contenuto)
├── scripts/vendor-assets.mjs → copia CSS e immagini di Leaflet in public/vendor/leaflet (generata, in .gitignore)
├── src/
│ ├── content/ → CONTENUTI del sito (vedi "Contenuti" sotto)
│ ├── server.ts → Express: compressione, robots.txt, sitemap.xml, statici, poi rendering Angular
│ ├── server/ → runtime-config.ts (env del server), sitemap.ts (funzioni pure)
│ ├── environments/environment.ts → default di sviluppo (URL sito/API/area riservata, indexable=false)
│ ├── styles.scss → `@use 'base'` (token, reset, tipografia, .btn, form: tutto in packages/styles)
│ └── app/
│ ├── app.ts / app.routes.ts / app.routes.server.ts / app.config(.server).ts
│ ├── core/ → site.ts (menu, slug validi, sitemap), app-config.ts, api.service.ts, auth.interceptor.ts, session.service.ts, cart.store.ts, seo.service.ts, structured-data.ts, format.ts, listing.ts (URL, ricerca e paginazione degli elenchi), blog.ts, shop.ts, configurator.ts (logica pura del wizard), consent.ts + consent.service.ts (cookie), form-guard.ts (anti-spam), contact.ts, showroom.ts, response-status.ts
│ ├── layout/ → header.ts, footer.ts, cookie-banner.ts
│ ├── shared/ → page-header, page-placeholder, not-found-view, solution-body, needs-picker, related-products, faq-list, quote-cta, latest-posts, post-card, product-card, list-search, pagination, filter-nav, placeholder-badge, consent-fields, honeypot, osm-map, legal-page
│ └── pages/ → un componente per pagina (export default, caricati lazy; la home ha home.html/home.scss)
```
**Convenzioni**:
- Componenti standalone con `ChangeDetectionStrategy.OnPush`, template e stili inline per i componenti piccoli, `input()`/`computed()`/`effect()`. I parametri di route arrivano come **input** (`withComponentInputBinding`). Le pagine sono `export default` e si caricano con `loadComponent: () => import('./pages/x')`.
- **TypeScript**: `tsconfig.json` estende `tsconfig.base.json` (strict, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`) con `module: preserve`, `moduleResolution: bundler`, lib DOM; `strictTemplates` ed extended diagnostics come errori.
- **Stili**: i token SCSS diventano custom properties su `:root` (`--color-primary`, `--space-4`, `--font-size-xl`…) e i componenti usano `var(--…)`. Breakpoint mobile-first con `@use 'mixins' as mq; @include mq.up(md) { … }` (`stylePreprocessorOptions.includePaths: ["../../packages/styles"]`, quindi basta `@use 'tokens'`/`'mixins'`; vedi "`packages/styles`"). Palette: blu acqua `#0b5c7a` (brand, uguale alle email), verde sostenibilità `#1f7a4d`, ambra energia `#f2a900`, un colore per macro-area (`--color-area-<slug>`). **Font di sistema**: nessuna richiesta a Google Fonts (GDPR, prestazioni).
- Locale `it-IT` registrato (`LOCALE_ID`), `<html lang="it">`.
**Contenuti** (`src/content/`): nell'MVP i testi di pagine istituzionali e soluzioni sono **file TypeScript versionati**, non un CMS. Ogni file esporta `default` con un helper tipizzato (`defineArea`, `defineSolution`, `defineCompanyPage` di `types.ts`), quindi un campo mancante o sbagliato è un errore di compilazione. ⚠️ **Tutti i testi attuali sono segnaposto** (commento in testa al file e `placeholder: true`): l'azienda fornirà quelli definitivi. In sviluppo le pagine con `placeholder: true` mostrano l'etichetta "Testo segnaposto" (in produzione no).
| File | Contiene | URL |
|---|---|---|
| `areas/<slug>.ts` | macro-area: label del menu, `order`, titolo, tagline, description (meta), intro, immagine, vantaggi, "come funziona", `relatedProducts`, FAQ, `configuratorPath` | `/soluzioni/<slug>` |
| `solutions/<area>/<slug>.ts` | soluzione: stessi campi più `area` | `/soluzioni/<area>/<slug>` |
| `company/<slug>.ts` | pagina azienda: titolo, description, lead, sezioni (paragrafi ed elenchi), CTA facoltativa | `/azienda/<slug>` (`chi-siamo` è `/azienda`) |
| `home.ts` | testi della home sezione per sezione, partner e recensioni (**esempi**: pubblicare solo marchi autorizzati e recensioni reali; niente JSON-LD Review finché non lo sono) | `/` |
| `needs.ts` | esigenze di "Qual è la tua esigenza?" → percorso del configuratore | — |
| `projects.ts` | realizzazioni (area, località, anno, sintesi, immagine, `featured` per la home) | `/realizzazioni` |
| `showroom.ts` | testi e foto della pagina showroom (segnaposto, foto in `public/images/showroom/`) | `/showroom` |
| `legal.ts` | privacy policy, cookie policy e tabella dei cookie (`validated: false` → avviso "DA VALIDARE CON CONSULENTE PRIVACY") | `/privacy`, `/cookie` |
| `company.ts` | dati aziendali `COMPANY` ⚠️ **da completare** (ragione sociale, P.IVA, indirizzo, telefono, email, coordinate, social): i campi `null` vengono omessi da footer, home e JSON-LD, mai inventati. Orari allineati a `apps/api/src/showroom/config.ts` | — |
- **Aggiungere una soluzione = creare un file** `src/content/solutions/<area>/<slug>.ts` (copiarne uno esistente). Il nome del file è lo slug (`a-z`, `0-9`, `-`), la cartella è l'area. Non serve toccare altro: route (`canMatch`), pagina dell'area, sitemap e footer la trovano da sole. Lo stesso vale per una nuova **pagina azienda** (`company/<slug>.ts`) o una nuova **area** (`areas/<slug>.ts` + almeno una soluzione; il colore `--color-area-<slug>` va aggiunto ai token e l'immagine in `public/images/aree/`).
- **Come funziona**: `scripts/content-index.mjs` scansiona le cartelle e scrive `src/content/generated/` (`paths.ts` con i soli slug per route e sitemap; `areas.ts`, `solutions.ts`, `company.ts` con gli import). Il builder Angular (esbuild) non supporta `import.meta.glob`, per questo serve il generatore. Parte da solo in `postinstall`, `predev`, `prebuild`, `pretest` e `pretypecheck`; la cartella è in `.gitignore`. **Con `npm run dev` già avviato**, dopo aver aggiunto un file lanciare `npm run content -w apps/public` (o riavviare il dev server).
- **Caricamento**: le aree (`content/areas.ts`) e le pagine azienda entrano nel bundle iniziale, perché servono a header e footer. Le soluzioni (`content/catalog.ts`) si caricano solo con le pagine (chunk lazy). Le route validano gli slug su `generated/paths.ts`, senza importare i contenuti.
- **Testi**: le `description` stanno tra 70 e 170 caratteri (lo verifica il test). Le risposte delle FAQ sono testo semplice, perché finiscono anche nel JSON-LD. Le immagini sono percorsi in `public/` con `alt` descrittivo e dimensioni intrinseche (per `NgOptimizedImage`).
- **Prodotti correlati**: `relatedProducts` contiene handle Medusa. La card legge `GET /shop/products/:handle` (prezzo IVA inclusa, link a `/shop/<handle>`). Un handle inesistente, o l'API spenta, fa sparire la card senza errori. Handle reali oggi: `addolcitore-domestico-20l`, `kit-fotovoltaico-6kw-accumulo`, `pompa-di-calore-aria-acqua-8kw`.
- **Validazione** (`src/content/content.spec.ts`): slug uguali ai nomi dei file, almeno una soluzione per area, campi obbligatori, lunghezza delle description, ordini univoci, FAQ → JSON-LD FAQPage valido, esigenze e realizzazioni su aree e percorsi esistenti, sitemap completa.
**Parametri di query verso altre pagine** (da leggere nei prompt 27-28):
- `/configuratore?percorso=acqua|fotovoltaico|pompa_calore|azienda`: percorso preselezionato, inviato da "Qual è la tua esigenza?" e dalle CTA "Calcola una stima". Il wizard accetta anche `?esigenza=` come alias, con gli stessi valori: il prompt 27 citava `?esigenza=acqua-calcarea`, ma il sito usava già `percorso` con i valori dell'API.
- `/contatti?oggetto=preventivo|sopralluogo|installatori|candidatura[&soluzione=<slug>][&prodotto=<handle>]`: oggetto precompilato del form contatti ("Richiedi un preventivo", "Prenota un sopralluogo", pagine azienda; `prodotto` dalla scheda prodotto dello shop).
**Route** (tutte lazy, `app.routes.ts`):
| Percorso | Note |
|---|---|
| `/` | home in 11 sezioni (hero con "Trova la soluzione giusta" e "Richiedi preventivo", settori con Acqua in evidenza, energia rinnovabile, servizi per privati e aziende, "Qual è la tua esigenza?", realizzazioni, partner, recensioni, ultimi articoli dall'API, contatti); JSON-LD Organization + WebSite + LocalBusiness |
| `/azienda`, `/azienda/:pagina` | da `content/company/`: chi siamo, certificazioni, partner, lavora con noi, con sotto-menu; `/azienda/chi-siamo` → **301** `/azienda`; slug sconosciuto → 404; JSON-LD BreadcrumbList |
| `/soluzioni/:area`, `/soluzioni/:area/:soluzione` | da `content/areas` e `content/solutions`: descrizione, vantaggi, come funziona, (area) elenco soluzioni, prodotti correlati, FAQ, CTA preventivo, (soluzione) altre soluzioni dell'area. JSON-LD **BreadcrumbList + FAQPage**. Valori sconosciuti → 404 (`canMatch`); `/soluzioni` → **301** `/` |
| `/realizzazioni` | galleria da `content/projects.ts`, filtro `?area=<slug>` fatto di link (funziona anche lato server); canonical sempre `/realizzazioni` |
| `/shop`, `/shop/categoria/:categoria`, `/shop/:handle`, `/carrello` | catalogo, scheda prodotto e carrello (vedi "Shop e carrello" sotto); carrello `noindex` |
| `/checkout` | checkout in 4 passi con Stripe (vedi "Checkout" sotto), `noindex` |
| `/ordine/:numero/confermato` | conferma d'ordine, `noindex` (numero non numerico → 404) |
| `/blog`, `/blog/categoria/:categoria`, `/blog/:slug` | elenco con ricerca e paginazione, dettaglio articolo (vedi "Blog" sotto) |
| `/configuratore`, `/configuratore/grazie` | wizard a regole e ringraziamento (vedi "Configuratore" sotto); `grazie` è `noindex` |
| `/contatti`, `/showroom`, `/privacy`, `/cookie` | vedi "Contatti, showroom e cookie" sotto |
| `**` | 404 con **status HTTP 404** (`RESPONSE_INIT`) e `noindex` |
- **404 decisi dalla pagina** (dati dall'API, es. articolo inesistente): `injectResponseStatus()` (`core/response-status.ts`) nel costruttore, poi `setStatus(404)` in un `effect` quando arriva la risposta. Funziona perché il server attende le richieste HTTP in corso prima di inviare la risposta. Contenuto con `<app-not-found-view>` e `setPage({ …, noindex: true })`. Con l'API giù le pagine rispondono **503** (non 404, così l'URL non esce dall'indice).
- **Render mode** (`app.routes.server.ts`): **tutte `RenderMode.Server`**, niente prerender, perché la configurazione arriva dall'ambiente a runtime e il prerender la fisserebbe alla build. Status 301 sui redirect.
**Configurazione runtime** (`core/app-config.ts`, `server/runtime-config.ts`): **la stessa build vale per qualunque dominio**.
- Il server legge `SITE_URL`, `PUBLIC_API_URL`, `API_INTERNAL_URL`, `PUBLIC_MEDIA_URL` (origine delle immagini pubbliche per la CSP, default `PUBLIC_API_URL`), `PRIVATE_APP_URL`, `SITE_INDEXABLE`, `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`/`pk_live_…`, validata all'avvio; vuota = checkout senza pagamento), `PORT` (default: `environment.ts`) e `NG_ALLOWED_HOSTS` (letta da Angular). Modello in `apps/public/.env.example`; in sviluppo non serve nessuna variabile. URL non validi → errore all'avvio.
- `SITE_CONFIG` (token): sul server viene dall'ambiente e finisce nel **TransferState**; il browser lo rilegge da lì (fallback `environment.ts`). `API_BASE_URL`: sul server è `API_INTERNAL_URL` (es. `http://api:3000` in Docker), nel browser `PUBLIC_API_URL`; se differiscono, `HTTP_TRANSFER_CACHE_ORIGIN_MAP` fa coincidere le chiavi della transfer cache.
- **`SITE_INDEXABLE=true` solo in produzione**: altrimenti tutte le pagine hanno `robots: noindex, nofollow` e `robots.txt` è `Disallow: /`.
- **Host ammessi** (protezione SSRF di Angular SSR): `angular.json` → `security.allowedHosts` contiene solo `localhost`/`127.0.0.1`; in produzione `NG_ALLOWED_HOSTS` = `DOMAIN`, impostata dal compose.
**`ApiService`** (`core/api.service.ts`): `get/post/patch/delete<T>(path, { query?, withCredentials? })` su `API_BASE_URL`; query senza valori vuoti; errori convertiti in **`ApiError { status, code, message, details }`** dal formato unico dell'API (rete/CORS → `NETWORK_ERROR`). `withCredentials: true` per le route con cookie (carrello). `provideHttpClient(withFetch())` + `provideClientHydration(withEventReplay(), withHttpTransferCacheOptions(...))`: **le GET fatte durante l'SSR non vengono ripetute** dal browser all'idratazione (le POST sì, mai in cache).
**`SeoService`** (`core/seo.service.ts`): `setPage({ title, description, path?, image?, type?, noindex?, prev?, next? })` imposta title (`<titolo> | ITI TECHNOLOGY`), description, robots, **canonical** (assoluto su `siteUrl`, assente se noindex), Open Graph (`og:title/description/url/image/type/site_name/locale`, immagine di default `/og-default.png`), `twitter:card` e, per gli elenchi paginati, `<link rel="prev|next">` (rimossi se assenti). `setJsonLd(id, data)` inserisce `<script type="application/ld+json">` (con `<` escapato) e `setPage` rimuove quelli della pagina precedente. Builder schema.org in `structured-data.ts`: `organizationJsonLd`, `websiteJsonLd`, `localBusinessJsonLd`, `breadcrumbJsonLd`, `faqJsonLd`, `blogPostingJsonLd` (campi null omessi). Ogni pagina chiama `setPage` nel costruttore, oppure in un `effect` se dipende dagli input di route.
**Elenchi con ricerca e paginazione** (blog e shop): funzioni pure in `core/listing.ts` (`parsePage`, `normalizeQuery`, `listQueryParams`, `listUrl`, `totalPages`, `pageWindow`, `plainTextExcerpt`) e componenti condivisi `app-list-search` (form GET + debounce), `app-pagination` (link veri `?pagina=n`), `app-filter-nav` (filtri a pillole fatti di link).
**Blog** (`pages/blog.ts`, `pages/blog-post.ts`, URL in `core/blog.ts`, card `shared/post-card.ts` usata anche da `latest-posts`):
- **URL**: elenco `/blog`, categoria `/blog/categoria/<slug>` (pagine vere, indicizzabili, in sitemap), ricerca `?q=`, pagina `?pagina=n` (9 articoli per pagina, `BLOG_PAGE_SIZE`). Tutto lo stato è nell'URL e arriva ai componenti come input: refresh, link condivisi e SSR mostrano la stessa pagina. Cambiando categoria si conserva `q` e si torna a pagina 1.
- **404 reali**: categoria inesistente (verificata su `GET /blog/categories`), `pagina` non valida (`abc`, `0`, `01`) o oltre l'ultima, articolo inesistente, in bozza o programmato (`POST_NOT_FOUND` dell'API). `pagina=1` è accettato, con canonical senza parametro.
- **Ricerca**: form `role="search"` con `method="get"`, quindi funziona anche prima dell'idratazione o senza JavaScript. Nel browser, digitando, aggiorna `?q=` dopo **350 ms** di pausa con `replaceUrl` (niente voce di cronologia per ogni pausa) e `scroll: 'manual'` (la pagina non torna in cima). Invio → navigazione normale. Il testo nel campo segue l'URL (back/forward) senza sovrascrivere ciò che si sta digitando. Messaggio `role="status"` con il numero di risultati; stato vuoto con link per togliere la ricerca.
- **SEO elenco**: canonical senza `q` (`/blog?pagina=2` è canonica di sé), `rel="prev|next"`, title con "pagina n". **Ricerche `noindex`**. Categorie con description propria (o generata) e BreadcrumbList.
- **SEO articolo**: title `seoTitle ?? title`, description `seoDescription ?? excerpt ??` estratto in testo semplice dal markdown (`plainTextExcerpt`, 160 caratteri), `og:type article`, `og:image` = copertina, canonical, JSON-LD **BlogPosting** (headline, date, immagine, `articleSection`; autore ed editore = l'azienda, perché l'API non espone l'autore) + **BreadcrumbList**.
- **Contenuto**: `contentHtml` dell'API inserito con `bypassSecurityTrustHtml`. L'API (sanitize-html con whitelist) resta **l'unico sanitizer**: quello di Angular toglierebbe `loading="lazy"` con un warning per ogni immagine. Se un giorno l'HTML arrivasse da un'altra fonte, va sanitizzato lì. Stili tipografici del corpo in `.prose` (con `::ng-deep`, perché l'HTML non ha gli attributi di incapsulamento).
- **Dettaglio**: copertina (16:9, `NgOptimizedImage` `fill` + `priority`), data, categoria (link alla pagina della categoria), CTA verso il configuratore (`QuoteCta` con `showConfigurator`, link senza percorso preselezionato) e fino a 3 **correlati** della stessa categoria (i più recenti se l'articolo non ne ha; se la richiesta fallisce la sezione sparisce).
**Sessione nell'app pubblica** (`core/session.service.ts`, `core/auth.interceptor.ts`):
- Il login avviene **solo nell'area riservata** (apps/private). L'app pubblica ritrova la sessione con il cookie di refresh `iti_rt`, condiviso tra i sottodomini (`AUTH_COOKIE_DOMAIN` = dominio padre in produzione; in dev i cookie di `localhost` valgono per tutte le porte).
- **Cookie indizio `iti_session=1`** (aggiunto all'API in questo prompt): impostato insieme a `iti_rt` a login e refresh, con la stessa scadenza e lo stesso dominio, ma **non httpOnly** e con `Path=/`, così l'app lo legge da `document.cookie`. Viene cancellato con `iti_rt` al logout e al refresh fallito. Non contiene segreti: è solo un'ottimizzazione, l'autorità resta il refresh token.
- All'avvio, nel browser, dopo l'idratazione (`afterNextRender` in `app.ts`):
1. senza `iti_session` → anonimo, **nessuna richiesta**. Così i visitatori anonimi non fanno un `/auth/refresh` (401) a ogni visita e non consumano il rate limit di 30/min per IP;
2. con `iti_session` → `POST /auth/refresh` con credenziali → access token **in memoria** (mai in storage) e `UserDto`. Refresh fallito → anonimo.
- `ApiService` ha l'opzione `auth: true` (usata da `/shop/*`): l'**interceptor** aggiunge `Authorization: Bearer …` se c'è un token valido, **senza attese**, così le richieste dell'idratazione partono subito e la transfer cache continua a servirle. Con token scaduto fa prima un refresh. Su un 401 fa un refresh e un solo nuovo tentativo, da anonimo se la sessione non c'è più.
- `SessionService.priceKey` vale `"public"` finché la sessione non è nota e per chi non è installatore, `installer:<id>` per un installatore: va nei `params` delle risorse del catalogo, che così si ricaricano col token solo per gli installatori. L'API decide poi se il profilo è approvato (`priceType`).
- **Refresh concorrenti**: ogni refresh ruota il token, e due refresh paralleli con lo stesso cookie contano come riuso (family revocata, utente disconnesso). Le schede dell'app pubblica si serializzano con un **Web Lock** (`navigator.locks`, nome `iti-auth-refresh`). L'area riservata è su un'altra origine e il lock non la copre: un refresh **esattamente simultaneo** nelle due app resta possibile (finestra di pochi millisecondi). Il prompt 29 deve usare lo stesso schema e lo stesso nome di lock; se il problema emergesse, la correzione va nell'API (es. tolleranza di pochi secondi sul riuso di un token appena ruotato).
- Sul server la sessione è sempre anonima: le pagine SSR hanno i prezzi pubblici.
**Shop e carrello** (`pages/shop.ts`, `pages/product.ts`, `pages/cart.ts`, `core/cart.store.ts`, `core/shop.ts`, card `shared/product-card.ts` usata anche da `related-products`). Solo route `/shop/*` di Fastify, mai Medusa:
- **Catalogo**: `/shop`, `/shop/categoria/<handle>`, `?q=` (max 100 caratteri, come l'API), `?pagina=n` (12 per pagina, `SHOP_PAGE_SIZE`): stesse regole del blog (stato nell'URL, ricerca con debounce e `noindex`, canonical, `rel prev/next`, BreadcrumbList per le categorie). 404 per categoria inesistente, pagina non valida o oltre l'ultima; 503 con Medusa o API giù.
- **Scheda prodotto** `/shop/<handle>`, renderizzata sul server con i prezzi pubblici:
- galleria (immagini del prodotto, altrimenti miniatura; miniature cliccabili);
- varianti come radio, con prezzo e disponibilità; proposta la prima disponibile con prezzo (`defaultVariant`);
- prezzo **IVA inclusa**. Per un installatore approvato: prezzo riservato con "listino" barrato;
- quantità (1-100) e "Aggiungi al carrello", con conferma `role="status"` ed errori dell'API (es. `OUT_OF_STOCK`) `role="alert"`;
- descrizione (testo semplice, `white-space: pre-line`);
- **schede tecniche**: `GET /documents?productRef=<handle>` **senza token**, filtrate su `visibility = public` (il link di download non può portare il token). Link diretto a `GET /documents/:id/download`;
- SEO: title, description (sottotitolo + descrizione, `plainTextExcerpt`), `og:type product`, `og:image`, canonical, JSON-LD **Product** (un `Offer` per variante con prezzo: `price` "5490.00", `EUR`, `InStock`/`OutOfStock`, `sku`) + BreadcrumbList. **Meta e JSON-LD solo con `priceType: standard`**: i prezzi riservati non sono un'offerta pubblica;
- 404 reale per handle inesistente (`PRODUCT_NOT_FOUND`), 503 se lo shop è giù.
- **Ricarica con i prezzi riservati senza sfarfallio**: elenco e scheda tengono visibile il valore precedente (`linkedSignal`) mentre la risorsa si ricarica con lo stesso handle o la stessa pagina.
- **`CartStore`** (signals: `cart`, `state`, `itemCount`, `isEmpty`, `busy`, `error`):
- si carica **solo nel browser** (`load()` in `app.ts` dopo l'idratazione, una volta), **dopo** `SessionService.ready()`: per un utente loggato l'API usa il carrello dell'account (e lo associa se era guest). Durante l'SSR il carrello non viene mai letto;
- tutte le chiamate usano `withCredentials` (cookie `cart_id`) e `auth: true`. `CART_NOT_FOUND` in lettura = carrello vuoto;
- operazioni (`add`, `setQuantity`, `adjust`, `remove`) **in coda**, una alla volta. `adjust(itemId, ±1)` calcola la quantità quando tocca a lei, sul carrello già aggiornato: due clic rapidi su "+" danno +2 (bug trovato nel test nel browser);
- ogni risposta contiene il carrello completo: righe, totali e badge si aggiornano appena risponde l'API;
- **sopravvive al refresh** perché lo stato vero è in Medusa, identificato dal cookie `cart_id` (httpOnly, 30 giorni).
- **Header**: icona carrello con badge (numero di pezzi, `aria-label` "Carrello, N prodotti"), sempre visibile anche su mobile. Su desktop (`xl`) logo, menu e carrello stanno su una riga con circa 25 px di margine a 1280 px: aggiungendo voci al menu, ricontrollare.
- **`/carrello`** (`noindex`): righe con miniatura, link al prodotto, prezzo unitario, quantità (− campo +, max 100), totale riga e "Rimuovi"; riepilogo con prodotti, sconto, spedizione ("calcolata al checkout" finché non è scelta), totale e IVA compresa; "Procedi al checkout" verso `/checkout` (prompt 26). Sul server mostra "Caricamento del carrello…".
- Budget del bundle iniziale portato a **380 kB** di warning (era 350): sessione, interceptor e store del carrello servono all'header su ogni pagina (353 kB, 99,6 kB compressi).
**Checkout** (`pages/checkout.ts|html|scss`, logica pura in `core/checkout.ts`, `core/stripe.ts`, `core/order-confirmation.ts`, conferma in `pages/order-confirmed.ts`):
- **Solo nel browser**: il server renderizza intestazione e "Caricamento del checkout…". La route resta `RenderMode.Server` perché la configurazione runtime arriva col TransferState (`RenderMode.Client` userebbe i default di `environment.ts`). All'avvio: `session.ready()` → `cart.reload()` → primo passo incompleto (`firstOpenStep`), con il form precompilato dal carrello: ricaricando la pagina si riprende da lì.
- **Passi** (quelli completati si riaprono con "Modifica", bloccati durante pagamento e completamento; riepilogo del carrello sempre a lato):
1. **Contatti**: email per l'ospite, con link "Accedi" → `PRIVATE_APP_URL/login?redirect=<SITE_URL>/checkout`. Con sessione mostra l'account: vale sempre l'email dell'account.
2. **Indirizzo**: spedizione (telefono obbligatorio nell'interfaccia, per il corriere; CAP 5 cifre; provincia = sigla), fatturazione facoltativa se diversa, **dati per la fattura** facoltativi (ragione sociale, P.IVA con cifra di controllo, codice fiscale, codice SDI, PEC), **consenso privacy obbligatorio** e marketing facoltativo. Gli errori di validazione dell'API (`details[].path`) tornano sui campi (`applyServerErrors`).
3. **Spedizione**: opzioni da `GET /shop/cart/shipping-options`, scelta attuale preselezionata.
4. **Pagamento**: a ogni ingresso `POST /shop/cart/payment` (payment session ricreata con l'importo aggiornato) + Stripe.js, poi **Payment Element** (`locale: 'it'`, aspetto dai token, dati di fatturazione precompilati). "Paga X €" → `stripe.confirmPayment({ redirect: 'if_required', return_url: <origin>/checkout })`.
- **3D Secure**: la finestra di autenticazione la apre `confirmPayment`. I metodi con redirect tornano su `/checkout?payment_intent_client_secret=…`: la pagina toglie i parametri dall'URL, rilegge il PaymentIntent (`retrievePaymentIntent`) e completa l'ordine se lo stato è `succeeded`/`requires_capture`/`processing`; altrimenti ripropone il pagamento.
- **Errori**: `card_error`/`validation_error` di Stripe mostrati col messaggio di Stripe (in italiano) sotto il modulo; carrello e PaymentIntent restano utilizzabili. Payment session non creata (Medusa o Stripe giù → 503) → messaggio e "Riprova". Pagamento riuscito ma `POST /shop/cart/complete` fallito → "Riprova a registrare l'ordine" (il pagamento non viene ripetuto).
- **Completamento**: `POST /shop/cart/complete` → ordine salvato in **sessionStorage** (`iti-last-order`), `CartStore.clear()`, navigazione a `/ordine/<numero>/confermato` con `replaceUrl`. La conferma mostra il riepilogo solo nella scheda in cui si è pagato: nessuna route dell'API espone un ordine dal numero, che sarebbe indovinabile. Altrove compare solo il messaggio generico. Ospite → "Crea un account" (`PRIVATE_APP_URL/registrazione?email=<email>`; gli ordini guest si collegano alla verifica dell'email); utente → `PRIVATE_APP_URL/ordini`.
- **Stripe.js** si carica solo al passo Pagamento (`@stripe/stripe-js/pure` con import dinamico): le altre pagine non scaricano nulla da js.stripe.com. Le origini Stripe sono nella CSP del server SSR (`src/server/csp.ts`).
- **Contratti per l'area riservata (prompt 29)**: `/login?redirect=<URL assoluto>` (dopo il login tornare lì, solo se l'URL è sul dominio del sito), `/registrazione?email=<email>` (email precompilata), `/ordini`.
- **Form**: reactive forms. Componenti `shared/form-field.ts` (etichetta, suggerimento, errore collegato con `aria-describedby`/`aria-invalid`, mostrato dopo il blur o l'invio, messaggi personalizzabili, `{ server: msg }` per gli errori dell'API) e `shared/address-fields.ts`. Stili globali in `styles.scss` (`.form-grid`, `.form-field`, `fieldset`, `.check`, `.alert`), da riusare nei form del prompt 28. ⚠️ Un `<form>` senza `[formGroup]` non emette `ngSubmit` (NgForm sta in `FormsModule`): usare `(submit)="$event.preventDefault(); …"`.
- `formatEuro` mostra sempre 2 decimali quando l'importo ha centesimi (`992,70 €`, prima `992,7 €`) e nessun decimale per gli interi (`1.190 €`).
- Bundle iniziale 366 kB (103 kB compressi); checkout lazy 80 kB.
- **Test** (`core/checkout.spec.ts`): validazioni del form (fattura e fatturazione solo se richieste, P.IVA, SDI, CAP, provincia, privacy), corpo della richiesta, precompilazione dal carrello, errori dell'API sui campi, primo passo, conferma da sessionStorage, `formatEuro`.
- **Verificato nel browser** (dev, API e Medusa reali, ospite): validazione dei contatti e dell'indirizzo (8 errori a form vuoto, CAP e P.IVA errati), salvataggio con fattura (`sdiCode` maiuscolo, P.IVA normalizzata) e consenso privacy registrato una volta (`source = checkout`), spedizione 15 € → totale 5.505 €, ricarica → ripresa dal passo Pagamento, senza `STRIPE_PUBLISHABLE_KEY` → messaggio di servizio, con chiave fittizia e Stripe segnaposto in Medusa → 503 gestito con "Riprova", "Modifica" → indirizzo precompilato, pagina di conferma (riepilogo, IVA, link di registrazione con l'email, `noindex`). **Non verificati**: pagamento reale, 3DS e carta rifiutata (manca la chiave Stripe di test).
**Configuratore** (`pages/configurator.ts|html|scss`, logica pura in `core/configurator.ts`, ringraziamento in `pages/configurator-thanks.ts`):
- **Nessuna domanda nel frontend**: step, domande (`single`, `multi`, `number`, `province`), opzioni, testi di aiuto e `visibleIf` arrivano da `GET /configurator/definition`. Una domanda aggiunta nell'API compare da sola, purché sia di uno dei 4 tipi (verificato aggiungendone una per prova). Un tipo nuovo richiede un nuovo `@case` nel template.
- **Visibilità e risposte** (`wizardState`): stesse regole di `validateAnswers` dell'API. Le condizioni si valutano in ordine, solo sulle risposte già valide delle domande precedenti. All'API vanno solo le risposte valide alle domande visibili. Le risposte a domande nascoste restano nello stato, così tornando a una scelta precedente si ritrovano, ma non vengono inviate (l'API le rifiuterebbe).
- **Valori grezzi** (`WizardValues`): i numeri restano testo finché non si inviano. `parseNumber` accetta il formato italiano (`1.500`, `1.500,5`, `2,5`). Per i numeri `<input type="text" inputmode="numeric|decimal">`, con l'intervallo "Da min a max unità" come suggerimento.
- **Validazione per step** (`stepErrors`): "Avanti" controlla le domande visibili dello step (obbligatorie, range, passo, opzioni, province, minimo di scelte). Un errore già mostrato si aggiorna mentre si corregge. Gli errori dell'API (`details[].path` = `answers.<id>`) riportano allo step della domanda.
- **Preselezione**: `?percorso=` (alias `?esigenza=`) imposta la **prima domanda a scelta singola del primo step**, se il valore è tra le sue opzioni (nessun id hardcoded), e parte dallo step successivo. Il parametro viene tolto dall'URL (`replaceUrl`). Se coincide con la risposta salvata si riprende da dove si era.
- **sessionStorage** (`iti-configurator`: versione della definizione, valori, posizione = id dello step o `result`), salvato a ogni modifica. Al refresh si riprende dallo stesso step, oppure dal primo step incompleto se la definizione è cambiata. Sul risultato si ricalcola con `evaluate`. Cancellato all'invio e con "Ricomincia da capo".
- **Solo nel browser**: il server renderizza intestazione, testo introduttivo e "Caricamento del configuratore…" (le risposte stanno nel sessionStorage). La pagina resta indicizzabile, con title, description e canonical veri.
- **Avanzamento**: "Passo X di Y" + `<progress>`. Y = step visibili + risultato. Prima della scelta del percorso si stima col percorso più lungo, così la barra non torna indietro.
- **Risultato** (`POST /configurator/evaluate`): soluzione, taglia, investimento, risparmio annuo e tempo di rientro (solo quelli non null), avvertenze, "Come abbiamo calcolato" (`details` in un `<details>`), incentivi, disclaimer dell'API (stima indicativa), prodotti suggeriti con `RelatedProducts` (card dello shop dagli handle; handle inesistenti spariscono). "Modifica le risposte" torna all'ultimo step.
- **Form finale**: nome, email (precompilati dall'account se c'è la sessione), telefono e messaggio facoltativi, privacy obbligatoria, marketing facoltativo → `POST /configurator/submit` con `auth: true` (con sessione la richiesta si collega all'utente). Errori dell'API sui campi (`body.contact.*`), 429 con un messaggio dedicato. Successo → riepilogo in sessionStorage (`iti-configurator-done`) e `/configuratore/grazie` (`replaceUrl`), che mostra nome, email e soluzione solo nella scheda che ha inviato.
- **Accessibilità**: ogni step è un `<form>` (Invio = Avanti). Scelte singole e multiple sono `fieldset`/`legend` con radio e checkbox native (tastiera nativa), opzioni a schede ≥ 44 px con focus visibile (`:has(input:focus-visible)`). `aria-invalid` e `aria-describedby` collegano aiuto ed errore. Al cambio step il focus va al titolo (`h2 tabindex="-1"`). Con errori va al primo campo non valido (`data-question`). Sul risultato va al titolo della soluzione.
- **Test** (`core/configurator.spec.ts`, 14 casi): visibilità per percorso e condizioni `in`/`includes`, risposte inviate (solo valide e visibili, numeri convertiti), risposta non valida che non sblocca le dipendenti, domanda aggiunta alla definizione che compare, messaggi di validazione, facoltative vuote, primo step incompleto, `parseNumber`, preselezione, errori dell'API, sessionStorage (anche dati corrotti).
- **Verificato nel browser** (dev, API reale): `?percorso=acqua` → step 2 e URL ripulito; "Avanti" a vuoto → 3 errori e focus sul primo campo; durezza visibile solo con "Sì"; refresh a metà e sul risultato → stesse risposte e stessa posizione; risultato acqua (1.500 €, 144 €/anno, 10,4 anni, card dell'addolcitore); validazione del form; invio → pagina di ringraziamento `noindex`. **Tutti e 4 i percorsi** arrivano al risultato e a una `quote_request` salvata con consenso (`source = configurator`; dati di prova poi cancellati). Idratazione senza errori.
- Chunk lazy `configurator` 31 kB; bundle iniziale invariato (366 kB).
**Contatti, showroom e cookie** (prompt 28):
- **Banner cookie** (`layout/cookie-banner.ts`, nessuna libreria esterna). Montato in `app.ts` dentro `@if (consent.panel() !== 'closed') { @defer { … } }`: il suo codice (6 kB) si scarica solo quando va mostrato, cioè alla prima visita o quando si riaprono le preferenze.
- Pulsanti "Rifiuta", "Accetta tutti" (stesso stile, un clic ciascuno) e "Personalizza". Le preferenze hanno Necessari (fissi), Statistiche e Marketing.
- Non modale; le preferenze sono un `role="dialog"` con focus sul titolo, Esc chiude.
- Riapribile da "Preferenze cookie" nel footer e dal pulsante della cookie policy.
- **`ConsentService`** (`core/consent.service.ts`): è l'unica fonte da interrogare prima di caricare script o contenuti che usano cookie o tracciano. Le API sono `granted('statistics' | 'marketing')` (reattivo), `whenGranted(cat)` (promise), `acceptAll`, `rejectAll`, `save`, `openPreferences`.
- Sul server e prima di `init()` (in `app.ts`, dopo l'idratazione) nessuna categoria è concessa.
- Oggi il sito **non usa nessuno script non necessario**: il servizio serve ai futuri.
- Revocare un consenso non scarica gli script già caricati: chi li carica deve gestirlo.
- **Cookie `iti_consent`** (`core/consent.ts`): valore `<versione>.<stat 0|1>.<mkt 0|1>.<secondi>`, `Path=/`, `Max-Age` 6 mesi (poi il banner ricompare), `SameSite=Lax`, `Secure` su https, senza `Domain` (solo l'host del sito), non httpOnly. Cambiando `CONSENT_VERSION` le scelte decadono.
- **Cookie tecnici del sito**: `iti_consent`, `iti_rt` e `iti_session` (solo dopo il login), `cart_id` (solo dopo un'aggiunta al carrello). Stripe (`__stripe_mid`, `__stripe_sid`) si carica solo al passo di pagamento. Elenco completo, sessionStorage compreso, in `COOKIE_TABLE` (`content/legal.ts`), mostrato in `/cookie`: **va aggiornato se si aggiunge un cookie**.
- **Anti-spam dei form** (`core/form-guard.ts` + `shared/honeypot.ts`):
- token da `GET /forms/token` chiesto dopo il render;
- se l'utente invia prima dei 3 s minimi, `FormGuard.submit` **attende** invece di farsi scartare in silenzio dall'API;
- `FORM_EXPIRED` → nuovo token e un solo nuovo tentativo;
- honeypot `website` fuori schermo, `tabindex="-1"`, `aria-hidden`.
- **Consensi dei form**: `shared/consent-fields.ts` (privacy obbligatoria con link, marketing facoltativo), usato da contatti e showroom.
- **`/contatti`** (`pages/contact.ts`):
- **Form**: nome, email, telefono, oggetto (facoltativi gli ultimi due), messaggio (10-5000 caratteri), consensi, honeypot → `POST /contact`. Successo → messaggio in pagina e "Invia un altro messaggio". Errori dell'API sui campi, 429 con un messaggio dedicato. Nome ed email precompilati dall'account.
- **Oggetto precompilato** da `?oggetto=` (`core/contact.ts`, solo i 4 valori previsti) più il titolo di `soluzione` (area o soluzione dai contenuti) o di `prodotto` (`GET /shop/products/:handle`, con l'handle come ripiego). Esempio: "Richiesta di preventivo: Pompa di calore aria-acqua 8 kW".
- **Dati aziendali**: solo quelli valorizzati in `COMPANY`, più orari e link allo showroom.
- **Mappa**: compare solo se `COMPANY.geo` è valorizzato (oggi `null`, quindi la sezione non c'è). È un pulsante "Mostra mappa": solo dopo il clic un `@defer` carica `shared/osm-map.ts`, con Leaflet 1.9 nella build ESM (`leaflet/dist/leaflet-src.esm.js`, tipi in `shared/leaflet-esm.d.ts`, chunk lazy da 37 kB compressi), il CSS (`/vendor/leaflet/leaflet.css`, aggiunto con un `<link>`) e le tile di `tile.openstreetmap.org`. Prima del clic nessuna richiesta a terzi. Marcatore vettoriale (`circleMarker`). C'è anche il link "Apri in OpenStreetMap".
- **Asset di Leaflet**: il builder Angular non accetta asset fuori da `apps/public`, mentre `node_modules` sta nella root del monorepo. Per questo `scripts/vendor-assets.mjs` li copia in `public/vendor/` (in `postinstall`, `predev`, `prebuild`).
- ⚠️ **Policy delle tile OSM**: va bene per traffico modesto; con molte visite valutare un provider di tile.
- **`/showroom`** (`pages/showroom.ts|html|scss`, contenuti in `content/showroom.ts`, funzioni pure in `core/showroom.ts`):
- **Contenuti**: testi, punti di forza, orari (`COMPANY.openingHoursText`) e 3 foto segnaposto (SVG).
- **Calendario** (solo nel browser, perché le disponibilità sono `no-store`): `GET /showroom/slots?from&days=14`. Giorni come pulsanti `aria-pressed` in una fila scorrevole, con il numero di orari liberi. Navigazione "Date precedenti / successive". Orari come radio con i posti liberi; quelli completi sono disabilitati. Si preseleziona il primo giorno con posti.
- **Orari** mostrati nel fuso della risposta (`Europe/Rome`), indipendenti da quello del browser.
- **Persone**: limitate al minimo tra i posti liberi dello slot e il massimo per prenotazione.
- **Invio**: `POST /showroom/bookings` → riepilogo "Ti aspettiamo martedì 6 ottobre 2026 alle 10:00 (2 persone)" con l'email di conferma inviata dall'API. `SLOT_FULL` / `INVALID_SLOT` → calendario ricaricato e messaggio sopra gli orari. La risposta 200 senza prenotazione (invio scartato dall'anti-spam) mostra un messaggio generico.
- **`/privacy` e `/cookie`** (`shared/legal-page.ts`, testi in `content/legal.ts`): testo segnaposto con avviso **"DA VALIDARE CON CONSULENTE PRIVACY"**, visibile anche in produzione finché `validated: false`. La cookie policy ha la tabella dei cookie, la scelta attuale e il pulsante per modificarla.
- **CSS globale**: `fieldset { min-width: 0 }`. Per default un fieldset è largo almeno quanto il contenuto, e la fila dei giorni lo faceva uscire dal pannello.
- **Test** (`core/consent.spec.ts`, 14 casi):
- formato del cookie, versione, attributi e assenza di `Domain`;
- primo accesso senza cookie scritti, rifiuto e accettazione con un clic, scelta persistente tra le visite, preferenze riaperte e modificate, `whenGranted`;
- date e orari dello showroom (anche con l'ora solare), primo giorno libero, limite delle persone;
- oggetto precompilato dei contatti.
- **Verificato nel browser** (dev, API reale):
- prima visita dopo aver cancellato i cookie → **nessun cookie** e nessuna risorsa di terzi, banner visibile;
- "Accetta tutti" o "Rifiuta" → solo `iti_consent`; dopo il refresh niente banner;
- preferenze riaperte dal footer, con le caselle sulla scelta attuale, poi modificate;
- contatti: errori (messaggio corto, privacy), oggetto precompilato dal prodotto, invio salvato; honeypot compilato → successo apparente e nulla salvato;
- mappa (con coordinate di prova, poi tolte) → Leaflet, CSS e tile caricati solo dopo il clic;
- showroom: errori senza slot e senza dati, prenotazione di martedì alle 10:00 per 2 persone confermata, **email `showroom-booking-confirmed` generata** (`.outbox`), posti dello slot da 6 a 4, consenso `source = showroom`. Dati di prova cancellati.
- Idratazione senza errori.
- **Non verificato**: il layout a 412 px nel browser (la finestra non si è ridimensionata); gli stili sono mobile-first.
- Bundle iniziale 376,5 kB (108 kB compressi; consenso e footer). Chunk lazy: showroom 22 kB, contatti 13 kB, banner 6 kB, mappa 12 kB + Leaflet 149 kB.
**Content-Security-Policy con nonce** (prompt 34, `src/server/csp.ts`):
- `src/index.html` ha `ngCspNonce="__CSP_NONCE__"` sul componente radice. La build copia il segnaposto come `nonce` su script e stili inline (contratto dell'event replay, CSS critici di beasties). Durante l'SSR lo usa anche lo script `__jsaction_bootstrap` (`CSP_NONCE` legge l'attributo).
- Express sostituisce il segnaposto con un nonce casuale per ogni risposta HTML e manda l'intestazione CSP: `script-src 'self' 'nonce-…'` + Stripe, `connect-src` API e Stripe, `img-src` `PUBLIC_MEDIA_URL` + tile OSM.
- Con `ng serve` (`isDevMode()`) la CSP non viene inviata.
- ⚠️ Un nuovo servizio esterno (script, iframe, immagini, API) va aggiunto in `buildCsp`, non nel Caddyfile.
- Verificato nel browser: idratazione e carrello funzionano, uno script inline iniettato viene bloccato.
**IP del visitatore verso l'API**: durante l'SSR `core/forwarded-for.interceptor.ts` inoltra all'API l'`X-Forwarded-For` ricevuto da Caddy. Così il rate limit dell'API vale per visitatore e non per il container del sito (vedi `TRUST_PROXY` nell'API).
**Server Express** (`src/server.ts`): **compressione gzip** (`compression`) di tutte le risposte, perché senza la home mobile scendeva a performance 85; Caddy non ricomprime ciò che è già compresso. Cache dei file statici: quelli con hash nel nome (`main-XXXXXXXX.js`, `chunk-…`, `styles-…`) `public, max-age=31536000, immutable`, gli altri (immagini, logo, favicon di `public/`) 1 ora.
**sitemap.xml e robots.txt** (`src/server.ts`, `server/sitemap.ts`): route Express prima del rendering Angular, attive anche con `ng serve`.
- `/sitemap.xml` = `staticSitemapPaths()` (home, pagine azienda, aree e soluzioni da `generated/paths.ts`, sezioni, privacy, cookie; niente carrello/checkout né URL duplicati) + categorie del blog (`/blog/categoria/<slug>`) e articoli da `GET {API_INTERNAL_URL}/sitemap-data` con `lastmod`. Cache in memoria 5 minuti. Con l'API giù: solo pagine statiche e nuovo tentativo dopo 60 s. Più categorie (`/shop/categoria/<handle>`) e prodotti (`/shop/<handle>`) dello shop da `GET /shop/products` (pagine da 50, max 5000) e `GET /shop/categories`; con Medusa giù la sitemap esce senza lo shop e si riprova dopo 60 s.
- `/robots.txt`: in produzione `Allow: /`, `Disallow: /carrello`, `/checkout` e `/ordine/`, riga `Sitemap:`; altrimenti `Disallow: /`.
**Layout**: header sticky con logo, macro-aree, Shop/Blog/Configuratore/Showroom/Contatti e pulsante "Area riservata" (`privateAppUrl`). Sotto 1280 px (`xl`) il menu si apre con l'hamburger (`aria-expanded`, chiusura a ogni navigazione). Footer con dati aziendali (solo quelli valorizzati), link alle aree, all'azienda e ai servizi, privacy e cookie. Link "Vai al contenuto", target touch ≥ 44 px, focus visibile.
**Test** (`ng test`, 71 casi): consenso cookie, showroom e contatti (`core/consent.spec.ts`), configuratore (`core/configurator.spec.ts`, vedi sopra), contenuti (`content.spec.ts`, vedi sopra), `SeoService` (tag, aggiornamento senza duplicati, noindex e sito non indicizzabile, JSON-LD senza dati inventati ed escapato), `ApiService` (URL e query, credenziali, `ApiError`, errore di rete), sitemap e robots (lastmod, categorie, escape XML, API giù, percorsi statici), elenchi (`core/listing.spec.ts`: parametro pagina, URL canonici, finestra di paginazione, estratto dal markdown, JSON-LD BlogPosting), sessione e carrello (`core/session-cart.spec.ts`: nessun refresh senza cookie indizio, refresh con credenziali, token solo sulle richieste `auth`, 401 → refresh e un nuovo tentativo, refresh rifiutato → anonimo, carrello che attende la sessione, 404 = vuoto, coda delle operazioni, due "+" rapidi → +2, errori; varianti, galleria e JSON-LD Product), sitemap dello shop.
**Verificato**:
- `npm run dev` e build SSR senza warning (initial 312 kB, 87 kB compressi).
- `curl` sulla build avviata con `SITE_URL`/`SITE_INDEXABLE`: HTML già renderizzato con title, description, robots, canonical, Open Graph e JSON-LD; aree e soluzioni 200; slug sconosciuti e pagina inesistente **404**; `/azienda/chi-siamo` e `/soluzioni` **301**; carrello `noindex`; robots e sitemap (30 URL con l'articolo del DB di sviluppo).
- Browser (dev): "Angular hydrated 5 component(s) and 199 node(s), 0 component(s) were skipped", nessun errore in console.
- **Lighthouse** sulla home (mobile, build di produzione): **SEO 100**, accessibilità 100, best practices 100 (nessun errore in console), performance 91 (senza compressione, che farà Caddy). Layout verificato a 1440 px e a 412 px.
- **Prompt 23** (build di produzione, API e Medusa reali):
- 11 soluzioni in 5 aree; ogni pagina ha title e canonical propri; aree e soluzioni hanno BreadcrumbList e FAQPage; coppie inesistenti → 404;
- card prodotto con prezzo dallo shop (`1.190 €` IVA inclusa); filtro realizzazioni (2 su 6 per Acqua);
- blog in home verificato con un articolo di prova creato e cancellato via API admin (il DB di sviluppo non ha articoli pubblicati, quindi la sezione è nascosta);
- hydration senza errori né avvisi di `NgOptimizedImage` su home, area, soluzione, realizzazioni e azienda;
- **Lighthouse home mobile: performance 98** (FCP 1,8 s, LCP 1,9 s, CLS 0), SEO, accessibilità e best practices 100. Bundle iniziale 338 kB (94 kB compressi).
- **Prompt 24** (build SSR + API reale, 12 articoli e 2 categorie di prova creati via API admin e poi cancellati):
- status: `/blog`, `?pagina=2`, categoria, ricerca (anche senza risultati), articoli → 200; `?pagina=3` oltre l'ultima, `?pagina=abc`, categoria inesistente, slug inesistente, `/blog/categoria` → **404**;
- HTML del server: title, description (`seoDescription` o estratto dal markdown), canonical, `rel prev/next`, ricerca `noindex` senza canonical, JSON-LD BlogPosting + BreadcrumbList, `<script>` del markdown assente dal corpo, correlati, CTA configuratore; sitemap con categorie e articoli;
- browser (dev): idratazione senza errori; ricerca con debounce → una sola navigazione, posizione di scroll invariata; filtro categoria che conserva `q`, pagina successiva, back, apertura di un articolo con 3 correlati.
- **Prompt 25** (build SSR + API + Medusa reali, i 3 prodotti di esempio; Medusa non ha categorie né immagini, quindi filtri e galleria sono verificati solo dai test):
- status: `/shop`, ricerca (anche senza risultati), scheda prodotto, `/carrello` → 200; `?pagina=2` oltre l'ultima, categoria inesistente, handle inesistente → **404**;
- HTML del server: prezzo `5.490 € IVA inclusa`, title, description, canonical, `og:type product`, JSON-LD Product (`price` "5490.00", `InStock`, `sku`) + BreadcrumbList; `/carrello` con "Caricamento del carrello…" e `noindex`; sitemap con i 3 prodotti;
- browser, anonimo: aggiunta di 2 pezzi → badge 2; refresh di `/carrello` → stesso carrello (10.980 €, IVA 1.980 €); due "+" rapidi → +2; quantità digitata; rimozione → carrello vuoto e badge 0;
- browser, installatore approvato (login fatto via `fetch` a `/auth/login`, come farà l'area riservata): `iti_session` presente → `/auth/refresh` → carrello → prodotto ricaricato col token → **4.650 €** con "listino 5.490 €" barrato, JSON-LD fermo a 5490.00; carrello dell'account a 4.650 €; dopo il logout nessun refresh, prezzi pubblici, badge 0;
- schede tecniche: un PDF `public` e uno `installer` collegati al prodotto via API admin (poi cancellati) → in pagina solo il pubblico, download 200 `application/pdf`;
- per il test l'installatore e2e `inst-e2e-1790970518032@example.com` del DB di sviluppo ha ora una password nuova (non salvata nel repo).
## `apps/private` (Angular SPA)
Package `@iti/private`: **Angular 22.2**, standalone, signals, zoneless, SCSS, **senza SSR** (è dietro login e non serve la SEO; `<meta name="robots" content="noindex, nofollow">`). Creato a mano sul modello di `apps/public`: stessi `tsconfig`, stesso builder, test vitest + jsdom.
```powershell
npm run dev -w apps/private # ng serve → http://localhost:4300 (porta già in CORS_ORIGINS dell'API)
npm run build -w apps/private # dist/private/browser (statici: in produzione stanno nell'immagine di Caddy; inlineCritical: false → nessuno script inline, CSP `script-src 'self'`)
npm run test -w apps/private # ng test (vitest + jsdom)
npm run typecheck -w apps/private
```
**Struttura**:
```
apps/private/
├── public/ → favicon, logo (copiati dall'app pubblica), vendor/leaflet (generata); in produzione anche config.json
├── scripts/vendor-assets.mjs → copia CSS e immagini di Leaflet in public/vendor/leaflet (postinstall, predev, prebuild; in .gitignore)
├── src/
│ ├── main.ts → legge config.json, poi bootstrap
│ ├── environments/environment.ts → default di sviluppo (apiUrl, siteUrl)
│ ├── styles.scss → `@use 'base'` (packages/styles) + .panel, .btn--block, .link-button
│ └── app/
│ ├── app.ts / app.config.ts / app.routes.ts
│ ├── core/ → app-config.ts, api.service.ts, auth.store.ts, auth.interceptor.ts, auth.guards.ts, roles.ts (aree, menu, homeFor), redirect.ts (?redirect= validato), format.ts, labels.ts (stati con etichetta e tono), download.service.ts, installer-profile.store.ts
│ ├── layout/ → auth-layout.ts (pagine di accesso), shell.ts|html|scss (area autenticata con menu)
│ ├── shared/ → form-field.ts (input o textarea, più password e minlength), form-errors.ts, state.ts (app-loading, app-empty, app-load-error), badge.ts, pager.ts, confirm-dialog.ts, location-picker.ts (Leaflet), leaflet-esm.d.ts
│ └── pages/ → auth/ (login, register, forgot-password, reset-password, verify-email, auth-helpers), customer/ (area clienti), installer/ (area installatori), admin/ (pannello admin), not-found.ts
```
**Configurazione runtime**: la stessa build vale per ogni dominio.
- `main.ts` legge **`/config.json`** prima del bootstrap (`loadRuntimeConfig`): `{ "apiUrl": "https://api.<dominio>", "siteUrl": "https://<dominio>", "medusaAdminUrl": "https://shop-admin.<dominio>/app" }`. Token `APP_CONFIG`. `medusaAdminUrl` (default `http://localhost:9000/app`) è il link al dashboard Medusa del pannello admin.
- URL non validi → warning e default. File assente (404, cioè in sviluppo) → default di `environment.ts` (`http://localhost:3000`, `http://localhost:4200`).
- In produzione `config.json` non è un file: lo risponde **Caddy** (`area.{$DOMAIN}/config.json`, `Cache-Control: no-store`) costruendolo da `DOMAIN` (`https://api.…`, `https://…`, `https://shop-admin.…/app`). Il Dockerfile di Caddy cancella un eventuale `config.json` dalla build.
**Sessione** (`core/auth.store.ts`, `AuthStore`):
- signals `status` (`unknown` → `anonymous` | `authenticated`), `user` (`UserDto`), `role`, `area` (`customer` | `installer` | `admin`), `isAuthenticated`.
- **Access token solo in memoria**, mai in localStorage o sessionStorage. Al refresh della pagina la sessione si ritrova con `POST /auth/refresh` (cookie httpOnly `iti_rt`).
- Come nell'app pubblica, il refresh all'avvio parte **solo se c'è il cookie indizio `iti_session`**: senza, l'utente è anonimo e non parte nessuna richiesta. Finché lo stato è `unknown`, `app.ts` mostra "Caricamento dell'area riservata…".
- `refresh()` è **deduplicato** (una sola promise in volo) e gira sotto il Web Lock **`iti-auth-refresh`**, lo stesso nome dell'app pubblica (vale tra schede della stessa origine). Resta il limite già noto: le due app sono su origini diverse, quindi un refresh esattamente simultaneo nelle due è possibile.
- Metodi:
- `login()`: `POST /auth/login` con `withCredentials`, necessario perché il browser salvi i cookie di una risposta CORS;
- `logout()`: `POST /auth/logout`, stato azzerato anche con l'API giù, poi `/login`;
- `reloadUser()` (`GET /me`), `navigateAfterLogin(redirect)`, `sessionExpired()`.
- Le chiamate dell'AuthStore e quelle anonime di `/auth/*` usano `SKIP_AUTH` (in `ApiService`: opzione `anonymous: true`), così l'interceptor non entra in un ciclo di refresh.
**Interceptor** (`core/auth.interceptor.ts`), solo per le richieste che iniziano con `apiUrl` (il token non va mai ad altri host):
1. aggiunge `Authorization: Bearer …`. Con token scaduto (margine 30 s) e sessione attiva fa prima il refresh. **Richieste parallele = un solo refresh**, perché attendono la stessa promise;
2. su **401** di una richiesta partita col token: se un'altra richiesta ha già rinnovato il token usa quello, altrimenti fa il refresh; poi **un solo** nuovo tentativo;
3. refresh rifiutato → `sessionExpired()`: stato azzerato e `/login?redirect=<pagina corrente>&scaduta=1`, con una sola navigazione anche se falliscono più richieste. Il login mostra "La sessione è scaduta".
- `ApiService` ha `get/post/put/patch/delete` ed errori `ApiError` come nell'app pubblica (`toApiError`).
**Route e ruoli** (`app.routes.ts`, `core/roles.ts`, `core/auth.guards.ts`):
| Percorso | Accesso | Note |
|---|---|---|
| `/login?redirect=&scaduta=` | anonimi (`guestGuard`) | contratto col sito: `redirect` = URL assoluto del sito (es. checkout) o percorso dell'app |
| `/registrazione?email=&redirect=` | anonimi | privato / azienda / installatore |
| `/forgot-password` | anonimi | percorso dei link delle email dell'API (`register-existing`) |
| `/reset-password?token=`, `/verify-email?token=` | tutti | link delle email dell'API |
| `/` | customer, company | panoramica (prompt 30) |
| `/ordini[/:numero]`, `/preventivi[/:id]`, `/assistenza`, `/assistenza/nuova?ordine=`, `/assistenza/:id`, `/documenti`, `/profilo` | customer, company, installer | area clienti (prompt 30) |
| `/installatori`, `/installatori/listino?q=&pagina=`, `/installatori/documenti?categoria=`, `/installatori/profilo` | installer | area installatori (prompt 31; `/installatori` è il link dell'email `installer-approved`) |
| `/admin`, `/admin/{richieste,preventivi,ticket,installatori,utenti,blog,documenti,contatti,showroom}[/:id]` | admin | pannello admin (prompt 32, vedi sotto; i `/:id` sono i link delle notifiche allo staff), più `/admin/blog/nuovo` e `/admin/blog/categorie` |
| `**` | autenticati | pagina non trovata |
- **Pagina iniziale per ruolo** (`homeFor`): customer/company → `/`, installer → `/installatori`, admin → `/admin`. Si usa dopo il login senza `redirect` e quando si apre una sezione non permessa.
- **`authGuard`** (sul layout autenticato): senza sessione → `/login?redirect=<url>`.
- **`roleGuard(...ruoli)`** come **`canMatch`** su ogni sezione, così il codice lazy di una sezione non permessa non viene scaricato.
- Ruolo non ammesso → `homeFor(ruolo)` (es. customer su `/admin/utenti` → `/`).
- Anonimo → login con l'URL richiesto (letto da `router.currentNavigation()`).
- Il controllo vero resta nell'API (`requireRole`).
- **`guestGuard`**: chi è già autenticato e apre login o registrazione va dove l'avrebbe portato il login.
- ⚠️ Nei guard asincroni tutte le `inject()` vanno **prima** del primo `await`: dopo, il contesto di injection non c'è più.
- Menu laterale per area in `MENUS` (`roles.ts`): l'installatore ha le sue voci più ordini, preventivi, assistenza e account.
- ⚠️ **Layout delle pagine di accesso con `matcher`**, non `path: ''`. Una rotta vuota con figli corrisponde anche all'URL `/` quando nessun figlio corrisponde, e `/` mostrava il layout di accesso vuoto (trovato dal test). `authPagesMatcher` lo limita ai 5 percorsi di accesso: una nuova pagina di accesso va aggiunta ad `AUTH_PAGES`.
- **`?redirect=` validato** (`core/redirect.ts`, contro gli open redirect). Ammessi:
- percorso dell'app (non `//host` né `/\host`);
- URL assoluto dell'app;
- URL assoluto con **la stessa origine di `siteUrl`** (navigazione esterna con `location.assign`).
- Tutto il resto, e le pagine di accesso stesse, → `homeFor(ruolo)`.
**Pagine di accesso** (`pages/auth/`, layout con logo e "← Torna al sito"):
- **Login**: errore unico "Email o password non corretti" (`INVALID_CREDENTIALS`), 429 con messaggio dedicato; dopo un errore la password viene svuotata.
- **Registrazione**:
- tipo di account come radio a schede; nome, cognome, telefono facoltativo, email, password (≥ 10) ripetuta, privacy obbligatoria (link a `siteUrl/privacy`), marketing facoltativo;
- azienda → ragione sociale e P.IVA obbligatorie; installatore → ragione sociale obbligatoria, P.IVA facoltativa (stesse regole dell'API);
- `registerBody` invia solo i campi pertinenti, senza stringhe vuote;
- risposta sempre 202 con il messaggio dell'API (anti-enumerazione) e "Vai al login";
- errori di validazione dell'API sui campi (`applyServerErrors`), focus sul primo campo non valido.
- **Password dimenticata**: messaggio dell'API, sempre uguale.
- **Reset password** e **verifica email**: `takeTokenFromUrl()` legge `?token=` e lo **toglie subito dall'indirizzo** (`replaceUrl`). Token non valido o scaduto → messaggio e link per richiederne uno nuovo. La verifica fatta da loggati rilegge l'utente (`GET /me`), così l'avviso sparisce.
- **Avviso email non verificata** nel layout autenticato, con "Invia di nuovo il link" (`POST /auth/verify-email/resend`, 429 gestito).
- Ogni pagina imposta il titolo della scheda (`Title`).
**Layout autenticato** (`layout/shell.*`):
- header sticky con logo, nome e ruolo, "Torna al sito" (`siteUrl`), "Esci";
- menu laterale da 1024 px (`lg`); sotto si apre con il pulsante "Menu" (`aria-expanded`) e si chiude a ogni navigazione;
- link "Vai al contenuto"; voce attiva con `aria-current="page"`.
**Area clienti** (prompt 30, `pages/customer/`; usata da privati, aziende e, salvo la panoramica, installatori):
| Pagina | Dati | Note |
|---|---|---|
| `/` Panoramica (`dashboard.ts` + `overview.ts`) | `/me/orders?limit=3`, `/me/quotes?limit=50`, `/me/tickets?limit=50` | 3 riquadri indipendenti: ultimi ordini, preventivi `sent` (da valutare), ticket non chiusi. Ognuno ha caricamento, vuoto ed errore propri (Medusa giù non blocca gli altri). `CustomerOverview` è riusabile nella home installatori (prompt 31) |
| `/ordini` | `GET /me/orders` | 10 per pagina, `?pagina=n`. Stato in una sola etichetta (`orderStatus` in `labels.ts`: annullato > consegnato > spedito > in preparazione > pagamento) |
| `/ordini/:numero` | `GET /me/orders/:number` | articoli con miniatura, totali con sconto e IVA, indirizzo, metodo di spedizione, tracking (link solo se http/https). 404/400 → "Ordine non trovato". Link "Apri una richiesta di assistenza" → `/assistenza/nuova?ordine=N` |
| `/preventivi` | `GET /me/quotes` | 10 per pagina; data di invio e validità |
| `/preventivi/:id` | `GET /me/quotes/:id` | importo, validità, **PDF** (`/me/quotes/:id/pdf`, salvato come `Preventivo <numero>.pdf`), **Accetta / Rifiuta** con dialogo di conferma. La risposta dell'API sostituisce il valore della risorsa (`res.set`): **stato aggiornato senza ricaricare**. 409 (scaduto, già risposto) → messaggio e rilettura |
| `/assistenza` | `GET /me/tickets` | filtro per stato con link a pillole (`?stato=`), 10 per pagina |
| `/assistenza/nuova` | `POST /me/tickets` | oggetto (≥ 3), priorità (bassa/normale/alta: "urgente" è riservata all'assistenza), messaggio; `?ordine=N` precompila "Ordine n. N". Dopo l'invio → conversazione con conferma (stato della navigazione) |
| `/assistenza/:id` | `GET /me/tickets/:id`, `POST …/messages` | messaggi del cliente e dell'assistenza (evidenziati), risposta che rimette il ticket aperto (testo "Scrivi per riaprire" se chiuso); 429 gestito |
| `/documenti` | `GET /documents?limit=100` (con token) | raggruppati per categoria (alfabetica, "Altri documenti" in fondo), tipo, dimensione, data; download con il token |
| `/profilo` | `PATCH /me`, `POST /auth/change-password`, `/me/consents`, `POST /me/delete-account` | vedi sotto |
- **Dati delle pagine**: `rxResource` + `ApiService`. Ogni sezione mostra `app-loading` (con `role="status"`), `app-empty` (messaggio e azione) o `app-load-error` (messaggio per 503/rete e "Riprova" → `reload()`). Le pagine di dettaglio tengono il contenuto durante una rilettura (`isLoading() && !hasValue()`).
- **Download protetti** (`core/download.service.ts`): un link non porterebbe l'access token, che sta solo in memoria. Il file si scarica con `HttpClient` (`responseType: 'blob'`, interceptor → `Authorization`) e si salva con un `<a download>` su un object URL. Il nome viene da titolo + estensione del MIME (`fileName`), non da `Content-Disposition`, che il CORS non espone. Gli errori arrivano come Blob e vengono riletti come JSON.
- **Dialogo di conferma** (`shared/confirm-dialog.ts`): `<dialog>` nativo con `showModal()` (focus intrappolato, Esc = Annulla, bloccato mentre l'operazione è in corso), variante `danger`.
- **Profilo** (`profile.ts|html`):
- **Dati personali**: nome e cognome; ragione sociale e P.IVA solo per `company` (obbligatorie). Email in sola lettura ("scrivi all'assistenza" per cambiarla). Per l'installatore un rimando al profilo aziendale (`/installatori/profilo`, prompt 31). Il salvataggio aggiorna l'utente nell'header (`AuthStore.setUser`).
- **Password**: attuale, nuova (≥ 10), ripetuta. La risposta è una nuova sessione (`AuthStore.applySession`), il cookie viene sostituito: la pagina resta autenticata anche dopo un refresh, gli altri dispositivi devono rifare il login.
- **Consensi**: stato del marketing con un pulsante per darlo o revocarlo (`POST`/`DELETE /me/consents/marketing`), registro dei consensi in un `<details>` (tipo, data, provenienza, stato), link all'informativa.
- **Cancellazione** (non per gli admin): password obbligatoria, motivo facoltativo, dialogo di conferma `danger` → `POST /me/delete-account` → `AuthStore.forget()` (niente logout: la sessione è già chiusa dall'API) e `/login?account=disattivato` con un messaggio. Password errata → errore sul campo, account intatto.
- **Stili globali** aggiunti in `styles.scss`: `.page-head`, `.back-link`, `.section`, `.item-list` / `.item` (righe cliccabili `a.item`), `.meta`, `.amount`, `.facts` (coppie etichetta/valore), `.btn--small`, `.btn--danger`.
- **Test** (`pages/customer/customer.spec.ts`, 8 casi): stato dell'ordine, gruppi di documenti, nome dei file, corpo di `PATCH /me`, `?pagina=`, importi e date; **dettaglio preventivo con HttpTestingController**: accetta → dialogo, POST, stato "Accettato" in pagina **senza rilettura**; annulla → nessuna richiesta; 409 → messaggio e rilettura.
- **Test API** (`apps/api/test/account.test.ts`, 10 casi): `PATCH /me` (nome, privato senza dati aziendali, email e ruolo ignorati, azienda non svuotabile, installatore, 401); cambio password (sessione nuova qui e altre chiuse, login con la nuova, password attuale errata, nuova corta o uguale); dettaglio ordine (indirizzo, spedizione, tracking, nessun id Medusa, 404 per ordini altrui o inesistenti, 400, 503). Suite completa: 218 test (223 dopo il prompt 32).
- **Verificato nel browser** (dev, API e Medusa reali): account azienda con l'email degli ordini guest di prova `webhook-e2e@example.com` → dopo la verifica l'ordine 6 compare in panoramica ed elenco (il 7, annullato, non viene collegato dal claim); dettaglio ordine 6 con articolo, totali, IVA e indirizzo; ordine 1 (di un altro cliente) → "Ordine non trovato"; due preventivi creati e inviati via API admin → "Da valutare"; accetta → dialogo, stato "Accettato" senza ricaricare; PDF scaricato col token (`Preventivo 2026-0001.pdf`, `application/pdf`; salvataggio intercettato nel test); ticket da `?ordine=6` (oggetto precompilato, errore a messaggio vuoto, priorità alta), risposta dell'assistenza via API admin, risposta del cliente → stato "Aperto"; documento `customer` nella categoria "Manuali" e scaricato; profilo: P.IVA svuotata → errore, salvataggio con il nuovo cognome nell'header, marketing dato e revocato con il registro, cambio password (attuale errata → errore sul campo; corretta → sessione mantenuta dopo il refresh); cancellazione (password errata → nulla cambia; corretta → `/login?account=disattivato`).
- Dati di prova cancellati (utente, preventivi, documenti e file, ticket, consensi). In Medusa l'ordine 6 resta collegato al customer con account creato per la prova.
- **Non verificati nel browser**: gli stati di errore con Medusa spento (coperti dai test API e dai componenti), il layout mobile.
**Area installatori** (prompt 31, `pages/installer/`; solo ruolo `installer`. Ordini, preventivi, assistenza, "Altri documenti" e account restano quelli dell'area clienti):
| Pagina | Dati | Note |
|---|---|---|
| `/installatori` (`installer-home.ts`) | profilo | **In attesa** (o profilo mai compilato): pagina di attesa con 4 passaggi (registrazione ✓, profilo completo con i dati mancanti, verifica dello staff, accesso) e "Completa il profilo". **Rifiutato**: esito e link all'assistenza. **Approvato**: data di attivazione, scorciatoie a listino, documentazione e profilo, più il riepilogo dell'area clienti (`CustomerOverview`) |
| `/installatori/profilo` (`installer-profile.ts|html|scss`, logica in `profile-form.ts`) | `GET`/`PUT /installer/profile` | dati aziendali (ragione sociale obbligatoria, P.IVA, telefono, indirizzo), zona di intervento (raggio 1-300 km, latitudine e longitudine), certificazioni (chip con aggiunta e rimozione, max 20, senza duplicati anche per maiuscole). Stato della richiesta in testa. Il salvataggio non cambia lo stato (lo decide l'admin) |
| `/installatori/listino` (`price-list.ts`) | `GET /shop/products` con il token | tabella prodotto / listino (barrato) / tuo prezzo / sconto % / "Ordina" → `siteUrl/shop/<handle>`; ricerca `?q=` e `?pagina=` (20 per pagina). Prezzi IVA inclusa; senza prezzo riservato il prodotto mostra il listino con "(listino)" |
| `/installatori/documenti` (`technical-docs.ts`) | `GET /documents?limit=100` con il token | solo i documenti con visibilità `installer`; filtro a pillole `?categoria=` con le categorie presenti (assegnate dall'admin: manuali, CAD, certificazioni, listini…); download col token |
- **`InstallerProfileStore`** (`core/installer-profile.store.ts`, root): `rxResource` di `GET /installer/profile` (404 = profilo mai creato → `missing`), `state` (`pending` | `approved` | `rejected` | `missing`), `approved`, `missing()` (dati mancanti: P.IVA, telefono, indirizzo, posizione, certificazioni), `save()` (`PUT`, aggiorna il valore). Condiviso tra le pagine: la home e il listino non rileggono il profilo.
- **Installatore non approvato**: listino e documentazione mostrano `app-approval-gate` (stato della richiesta, dati mancanti, link al profilo) **e non fanno nessuna richiesta** (`params` della risorsa `undefined` finché non è approvato). Le pagine usano `@if (store.approved()) { … } @else { <app-approval-gate /> }`: con un `<ng-content>` il contenuto proiettato verrebbe istanziato comunque (e la richiesta partirebbe). Il controllo vero resta nell'API (prezzi standard e niente documenti `installer` ai non approvati).
- **Prezzo di listino a confronto**: `ShopProductSummaryDto.fromOriginalPrice` (nuovo campo dell'API) è il prezzo pubblico della stessa variante di `fromPrice`. Sconto = `discountPercent(fromPrice, fromOriginalPrice)`.
- **Selettore su mappa** (`shared/location-picker.ts`):
- Leaflet 1.9 (build ESM `leaflet-src.esm.js`, chunk lazy), CSS da `vendor/leaflet/leaflet.css` copiato da `scripts/vendor-assets.mjs`, tile di `tile.openstreetmap.org`;
- si carica **solo** con il pulsante "Scegli il punto sulla mappa" / "Mostra sulla mappa" (`@defer` dentro un `@if`), come la mappa del sito pubblico: prima nessuna richiesta a OpenStreetMap;
- clic sulla mappa → punto (5 decimali) nei campi latitudine/longitudine; punto fuori dall'Italia → messaggio, campi invariati. Marcatore vettoriale (`circleMarker`) e **cerchio del raggio di servizio**, che segue il campo del raggio; la mappa inquadra il cerchio quando cambia il raggio;
- **accessibilità**: latitudine e longitudine sono anche campi del form (accettano `45,58` o `45.58`), sincronizzati con la mappa; "Rimuovi la posizione" li svuota;
- validazione: entrambe o nessuna, dentro `ITALY_BOUNDS` (ora in `@iti/shared`, gli stessi confini dell'API).
- CSP dell'area riservata (Caddyfile): `img-src` comprende `https://tile.openstreetmap.org`.
- **Menu dell'installatore**: area installatore, listino, documentazione tecnica, profilo aziendale, poi ordini, preventivi, assistenza, "Altri documenti" (`/documenti`: quelli per tutti i clienti) e "Il mio account" (dove i dati aziendali rimandano al profilo aziendale).
- **Test** (`pages/installer/installer.spec.ts`, 8 casi): dati mancanti del profilo; coordinate (formato italiano, una sola compilata, fuori Italia); corpo di `PUT /installer/profile` (trim, null, certificazioni senza duplicati); sconto e categorie; **in attesa → nessuna richiesta di listino e documenti, avviso mostrato**; approvato → listino con il token (5.490 € barrato, 4.650 €, −15%, link allo shop) e solo documenti `installer` filtrati per categoria. Test API: `fromOriginalPrice` nel catalogo dell'installatore (`shop.test.ts`).
- **Verificato nel browser** (dev, API e Medusa reali, installatore registrato per la prova e poi cancellato):
- in attesa: pagina con i 4 passaggi e i 5 dati mancanti; listino e documentazione con l'avviso e **nessuna richiesta** a `/shop/products` o `/documents` (controllato nel traffico di rete);
- profilo: raggio non valido → errore; coordinate di Parigi → "La posizione deve essere in Italia"; certificazioni con un duplicato in minuscolo scartato; mappa aperta (tile, punto e cerchio da 40 km), **clic sulla mappa → coordinate 45,77749 / 9,82775 nei campi**; salvataggio → in DB `service_radius_km = 40`, punto `45.77749, 9.82775`, certificazioni, stato `pending`;
- **ricerca admin `/admin/installers/nearby`**: da Bergamo, in attesa → nessuno; dopo l'approvazione → l'installatore a **14,7 km** (raggio 40); da Milano (~60 km) → escluso;
- approvato: listino con i prezzi riservati reali di Medusa (4.650 / 990 / 8.400 € contro 5.490 / 1.190 / 9.900 €, −15/−17/−15%), ricerca "pompa" → 1 risultato, link allo shop del sito; documentazione con filtri Tutte/CAD/Manuali e filtro CAD funzionante; home con scorciatoie e riepilogo;
- rifiutato via API admin: home "Richiesta non approvata" e listino non disponibile.
- Dati di prova cancellati (utente, profilo, documenti e file, consensi); in Medusa resta il customer `cus_01M41BW3W1Y4CPCR97WWRNJB37` (tolto dal gruppo installatori dal rifiuto).
- **Non verificati**: layout mobile, uso della mappa da tastiera (c'è il fallback dei campi).
**Pannello admin** (prompt 32, `pages/admin/`; solo ruolo `admin`: `roleGuard('admin')` come `canMatch` sul ramo `/admin`, controllo vero nell'API con l'hook `requireRole("admin")`). Il catalogo (prodotti, prezzi, listini, giacenze, ordini) **non** è qui: si gestisce dal dashboard Medusa, con il link "Catalogo e ordini ↗" nel menu e un riquadro in dashboard (`medusaAdminUrl`).
| Pagina | Dati | Note |
|---|---|---|
| `/admin` (`admin-dashboard.ts`) | `GET /admin/summary` (nuovo), richieste `new`, visite da adesso | 6 contatori cliccabili verso l'elenco filtrato (richieste nuove, ticket da gestire, installatori in attesa, visite in programma, contatti nuovi, preventivi in attesa di risposta), ultime richieste, prossime visite, link a Medusa |
| `/admin/richieste[/:id]` | `/admin/quote-requests` | elenco con filtro stato e ricerca. Dettaglio: contatti, messaggio, **risultato del configuratore** (soluzione, stime, avvertenze, prodotti, calcolo), **risposte con le etichette** della definizione attuale (`GET /configurator/definition`; senza definizione gli id, avviso se la versione è cambiata), **installatori in zona** (`/admin/quote-requests/:id/installers`, messaggio se manca la provincia), cambio stato, preventivi collegati e **nuova bozza** |
| `/admin/preventivi[/:id]` | `/admin/quotes` | elenco per stato. Dettaglio: dati, modifica solo in bozza, **PDF** (carica/sostituisci, scarica da `/documents/:id/download`), **invio** con dialogo (avvisa se manca il PDF), reinvio. La creazione parte da una richiesta o dalla scheda utente (`QuoteForm`; importo in formato italiano `7.450,50` → `parseEuroToCents`) e porta al dettaglio |
| `/admin/ticket[/:id]` | `/admin/tickets` | filtri stato e priorità, ricerca; conversazione, risposta (email al cliente) con lo stato da assumere (default "in attesa del cliente"), cambio stato/priorità |
| `/admin/installatori[/:id]` | `/admin/installers` | filtro stato e ricerca. Dettaglio: dati, zona (link a OpenStreetMap, nessuna mappa caricata), note interne, **approva/rifiuta** con dialogo (motivo nell'email di rifiuto), "Riallinea con lo shop" (`/sync`). `medusaSynced: false` → avviso |
| `/admin/utenti[/:id]` | `/admin/users` (nuovo) | filtri ruolo e stato, ricerca per email/nome/azienda. Scheda: account, profilo installatore, preventivi, nuovo preventivo, link ai ticket (`?q=email`), **disabilita/riabilita** con dialogo (non sul proprio account) |
| `/admin/blog`, `/admin/blog/nuovo`, `/admin/blog/:id`, `/admin/blog/categorie` | `/admin/blog/*` | elenco (stato "Programmato" per date future), **editor** (vedi sotto), categorie con aggiunta, modifica inline, eliminazione |
| `/admin/documenti` | `/admin/documents` | caricamento (file, titolo proposto dal nome, **visibilità obbligatoria** da scegliere, categoria, handle prodotto), filtro per visibilità, ricerca, modifica inline dei metadati, download, eliminazione con dialogo |
| `/admin/contatti[/:id]`, `/admin/showroom[/:id]` | `/admin/contact-requests`, `/admin/showroom/bookings` | elenchi con filtri; dettaglio con cambio stato (`StatusSelect`). Showroom di default **da oggi** (`?periodo=tutte` anche le passate); "Confermata" invia l'email, `SLOT_FULL` con un messaggio dedicato |
- **Convenzioni**: elenchi in `.data-table` dentro `.table-wrap` (scorre in orizzontale su mobile); stato dei filtri nell'URL (`?stato=`, `?q=`, `?pagina=`; 20 per pagina, documenti 50); componenti `shared/list-filters.ts` (`app-filter-pills`, `app-search-box`); etichette degli stati lato staff in `core/labels.ts` (`ADMIN_TICKET_STATUS`, `ADMIN_QUOTE_STATUS`, `QUOTE_REQUEST_STATUS`, `INSTALLER_STATUS`, `CONTACT_STATUS`, `BOOKING_STATUS`, `BLOG_STATUS`, `VISIBILITY_LABELS`, `optionsOf`). Ogni azione aggiorna la pagina con la risposta dell'API (`res.set`), errori in un `.alert`. Upload multipart con `FormData` (`pages/admin/upload.ts`).
- **Editor del blog** (`blog-editor.ts|html|scss`, funzioni pure in `markdown-edit.ts`):
- textarea markdown con barra minima: grassetto, corsivo, H2, H3, elenco, citazione, link, **immagine** (upload su `POST /admin/uploads/image` e inserimento di `![Descrizione dell'immagine](url)` su una riga a sé, con il testo alternativo selezionato da sostituire). Scorciatoie Ctrl/Cmd+B, I, K. Ogni azione rimette il focus nella textarea con la parte da completare selezionata;
- **anteprima live**: `POST /admin/blog/preview` dopo **400 ms** di pausa (`debounceTime` + `switchMap`), stesso renderer e sanitizer del sito; l'HTML precedente resta visibile mentre arriva il nuovo. Mostra titolo, estratto, data, categoria, copertina e corpo con i mixin di `_prose.scss`. Scrittura e anteprima sono affiancate quando il pannello è largo almeno 56rem (**container query**), altrimenti ci sono le schede "Scrivi / Anteprima";
- colonna laterale: bozza/pubblicato, data di pubblicazione (`datetime-local` in ora locale; futura = programmato), categoria, copertina (upload, sostituzione, rimozione), SEO (titolo ≤ 70 e descrizione ≤ 160 con contatori), slug (facoltativo alla creazione; in modifica inviato solo se cambiato, con avviso sui link condivisi; `SLUG_TAKEN` sul campo), elimina;
- dopo la creazione si passa a `/admin/blog/:id` (`replaceUrl`); link "Vedi sul sito" quando è pubblicato e la data è passata.
- **Test** (`pages/admin/admin.spec.ts`, 11 casi): importi in euro, azioni della barra markdown, immagine inserita, `datetime-local` ↔ ISO, corpo dell'articolo (vuoti → null, slug), stato programmato, risposte del configuratore con etichette, metadati dei documenti; **editor**: digitazione rapida → una sola richiesta di anteprima dopo la pausa, HTML in `.prose`; grassetto sulla selezione e salvataggio come pubblicato → navigazione all'articolo; **installatore**: approva → dialogo, POST, stato aggiornato, avviso con `medusaSynced: false`.
- **Verificato nel browser** (dev, API, Medusa e sito reali):
- login admin → dashboard con i contatori (1 installatore in attesa);
- editor: titolo, estratto, testo con H2 dalla barra e grassetto con Ctrl+B, anteprima affiancata aggiornata; pubblicato → `/blog/prova-editor-addolcitore-e-calcare` sul sito con **lo stesso HTML** dell'anteprima e gli stessi stili calcolati (corpo 18 px, h2 32 px, larghezza 72ch);
- installatore registrato per la prova: prima prezzi standard (549000) e nessun documento; **approvato dal pannello** → con lo stesso token prezzo riservato 465000 (listino 549000) e il documento "installatori" caricato dal pannello (senza visibilità scelta → errore);
- utente: ricerca, scheda, nuovo preventivo `7.450,50` → bozza `2026-0001`, PDF caricato, inviato (stato "Inviato");
- categorie, ticket, showroom (filtri) senza errori in console. Gli elenchi con dati di ticket, contatti, showroom e richieste sono verificati solo dai test e dallo stato vuoto (il DB di sviluppo non ne ha).
- Dati di prova cancellati (articolo, documenti e file, preventivo, utente); in Medusa resta il customer `cus_01M4632M63A3D0828HATXM5K3V`, tolto dal gruppo installatori.
- **Non verificati**: upload di immagini nell'editor dal browser (coperto dalle funzioni pure e dai test dell'API), layout mobile.
- Build: bundle iniziale 338 kB (95 kB compressi; era 320 kB); editor 28 kB, le altre pagine admin 3-13 kB.
**Test** (`core/auth.spec.ts`, 19 casi):
- sessione: nessun refresh senza cookie indizio; sessione ritrovata col cookie e token assente dagli storage; refresh rifiutato → anonimo; login con credenziali;
- interceptor: bearer solo verso l'API; **token scaduto con 3 richieste parallele → 1 refresh**; **401 su 2 richieste parallele → 1 refresh e un nuovo tentativo ciascuna**; refresh fallito → logout e login con `scaduta`;
- guard: customer su `/admin` → `/`; admin su `/ordini` → `/admin`; installatore su `/` → `/installatori`, ma con accesso ai preventivi; company fuori da `/installatori`; anonimo → login con `redirect`; autenticato su `/login` → sua area;
- redirect validato (open redirect, `javascript:`, `//`, `/\`); `config.json`; corpo della registrazione.
**Verificato nel browser** (dev, API reale):
- anonimo su `/admin/utenti` → `/login?redirect=%2Fadmin%2Futenti`;
- registrazione azienda: errori a form vuoto (ragione sociale e P.IVA comprese), poi 202 → utente `company` con ragione sociale e P.IVA in DB;
- login con `?redirect=/preventivi/abc` → quella pagina, menu clienti, avviso email da confermare; nessun token in localStorage o sessionStorage;
- **refresh della pagina → sessione mantenuta** (un solo `POST /auth/refresh`);
- utente azienda su `/admin/installatori` → `/` (Panoramica);
- link di verifica da loggato → "Email confermata", token tolto dall'URL, avviso sparito;
- da loggato, `/login?redirect=https://evil.example.com/` → `/`; `?redirect=http://localhost:4200/carrello` → pagina del sito, che **ritrova la stessa sessione** (in dev i cookie di localhost valgono per tutte le porte);
- "Esci" → `/login`; poi `/ordini` → login **senza nessuna richiesta all'API**.
- Dati di prova cancellati dal DB applicativo. In Medusa resta il customer `cus_01M419PVYHKC33FVWKYWMXBQ4R` (`azienda-p29-…@example.com`).
- **Non verificati nel browser**: refresh trasparente a token scaduto e 401 concorrenti (coperti dai test), login admin e installatore (coperti dai test dei guard), layout mobile.
- Build di produzione: bundle iniziale 292 kB (80 kB compressi); pagine di accesso in chunk lazy (registrazione 9 kB).
- Prompt 30: bundle iniziale 309 kB (86 kB compressi); pagine dell'area clienti in chunk lazy (profilo 14 kB, le altre 3-7 kB).
- Prompt 31: bundle iniziale 320 kB (90 kB compressi: crescono i chunk comuni precaricati, come i form); pagine installatori 5-14 kB, Leaflet 149 kB nel suo chunk, caricato solo aprendo la mappa.
## `apps/api` (Fastify)
Package `@iti/api`: ESM, TypeScript strict (estende `tsconfig.base.json`, NodeNext → import relativi con estensione **`.js`**), Fastify 5, Node 24.
```powershell
npm run dev -w apps/api # tsx watch src/server.ts → http://localhost:3000
npm run build -w apps/api # tsc -p tsconfig.build.json → dist/
npm run start -w apps/api # node dist/server.js
npm run test -w apps/api # vitest (test/**/*.test.ts, app.inject senza rete)
npm run typecheck -w apps/api
```
- ⚠️ Una sola istanza di `npm run dev -w apps/api` alla volta: se ne restano aperte altre (es. da sessioni precedenti), al primo salvataggio si riavviano tutte insieme, si contendono la porta 3000 e l'API smette di rispondere. Chiuderle tutte (processi `tsx watch src/server.ts`) e riavviarne una.
**Struttura**:
```
apps/api/
├── src/
│ ├── server.ts → entry: carica config (exit 1 se non valida), listen, shutdown su SIGINT/SIGTERM
│ ├── app.ts → buildApp(config): istanza Fastify senza listen (usata anche dai test)
│ ├── errors.ts → AppError(status, code, message), tipo ErrorBody, codici di default per status
│ ├── config/
│ │ ├── env.ts → schema zod delle env, loadConfig() (legge apps/api/.env con process.loadEnvFile)
│ │ └── logger.ts → opzioni pino per ambiente
│ ├── plugins/ → config (app.config), error-handler, security (helmet/cors/cookie/rate-limit)
│ ├── routes/
│ │ ├── index.ts → registrazione di tutte le route
│ │ ├── public/ → senza prefisso: /health, /blog, /shop, /configurator, /login, ...
│ │ ├── private/ → senza prefisso, utente autenticato: /me, /me/orders, ...
│ │ ├── admin/ → prefisso /admin, solo ruolo admin
│ │ └── webhooks/ → prefisso /webhooks (es. /webhooks/medusa)
│ ├── services/ → logica di dominio, client esterni: auth, email, password, tokens, medusa-client, medusa-sync, shop, medusa-webhooks, storage, documents, blog
│ ├── emails/ → template email (HTML + testo, layout comune) e script di anteprima
│ ├── configurator/ → wizard: definition.ts (domande), rules.ts (soglie e tabelle), engine.ts (calcolo puro)
│ ├── showroom/ → config.ts (orari, durata visita, capienza, chiusure), slots.ts (calcolo slot, puro)
│ └── db/
│ ├── schema/ → tabelle Drizzle (users.ts, ...) + index.ts
│ ├── migrations/ → SQL generato da drizzle-kit (versionato)
│ ├── client.ts → createDb(url)
│ ├── migrate.ts → script db:migrate
│ └── seed.ts → script db:seed
├── drizzle.config.ts → config drizzle-kit (db:generate / db:studio)
└── test/
```
**Convenzioni**:
- **Configurazione**: tutte le env passano da `src/config/env.ts` (schema zod), mai `process.env` sparso nel codice. Si usa `app.config.X`. Ogni nuova variabile va aggiunta allo schema **e** a `apps/api/.env.example`. In produzione sono rifiutati anche `AUTH_COOKIE_SECURE=false`, origini CORS non https ed `EMAIL_OUTBOX_DIR` (prompt 34). All'avvio, con env mancanti o non valide, il server stampa l'elenco completo (`Configurazione non valida: - VAR: motivo`) ed esce con codice 1. `VAR=` vuota conta come non impostata.
- ⚠️ **zod 4: i `.default()` valgono anche dentro `.partial()`**: `z.object({ status: z.enum([...]).default("draft") }).partial().parse({})` → `{ status: "draft" }`. Uno schema PATCH ricavato da uno schema con default sovrascriverebbe i campi non inviati. Regola: schema base **senza default**, la creazione lo estende con i default, il PATCH usa `base.partial()` (vedi `admin/blog.ts`, `admin/documents.ts`).
- **Validazione: zod via `fastify-type-provider-zod`** (scelto rispetto a TypeBox: stesso linguaggio di schema della config e di Medusa, tipi inferiti). Ogni route dichiara `schema: { body/querystring/params, response: { 200: ... } }` con schemi zod. Le route si scrivono come `FastifyPluginAsyncZod` per avere i tipi inferiti. Anche le risposte sono validate: un output non conforme dà 500 e finisce nei log.
- **Errori**: formato unico `{ "error": { "code": "...", "message": "..." } }`. Per errori applicativi `throw new AppError(409, "EMAIL_TAKEN", "Email già registrata")`; con un quarto argomento facoltativo `details: [{ path, message }]` (es. validazioni calcolate dal codice, come le risposte del configuratore). Validazione → 400 `VALIDATION_ERROR` con `details: [{ path: "body.email", message }]`. Route inesistente → 404 `NOT_FOUND`. Rate limit → 429 `RATE_LIMITED`. Errori di Fastify o dei plugin → codice derivato dallo status (`STATUS_CODES`). Mai stack nella risposta; in **produzione i 5xx rispondono `"Errore interno"`** e il dettaglio va solo nei log.
- **Logging (pino)**: development → console con pino-pretty; production → JSON su stdout (log Docker) **e** file in `LOG_DIR` con rotazione `pino-roll` (`api.YYYY-MM-DD.N.log`, giornaliera o a 50 MB, ultimi 14 file); test → spento. Redatti `authorization`, `cookie`, `x-internal-secret`, `x-signature`, `set-cookie`. Request id letto da `x-request-id` (Caddy) o generato. Nel codice si logga con `request.log` / `app.log`, mai `console`.
- **Sicurezza**: helmet (CORP `same-site`, perché i frontend sono su sottodomini dello stesso dominio); CORS solo su `CORS_ORIGINS` con `credentials: true`; `@fastify/cookie` con `COOKIE_SECRET` (cookie firmati); rate limit globale per IP `RATE_LIMIT_MAX`/`RATE_LIMIT_WINDOW` (default 300/minuto), che le route possono abbassare (`config: { rateLimit: { max, timeWindow } }`) o escludere (`/health`); `TRUST_PROXY` = elenco dei proxy fidati: in produzione `loopback,uniquelocal`, cioè Caddy e server SSR sulla rete Docker (accetta anche `true`/`false`). Body JSON max 1 MB.
- `GET /health` → `{ status: "ok", uptime, timestamp }`, escluso dal rate limit.
- **Env attuali**: `NODE_ENV`, `HOST` (0.0.0.0), `PORT` (3000, porta a cui Medusa manda i webhook), `LOG_LEVEL`, `LOG_DIR` (obbligatoria in produzione), `TRUST_PROXY`, `CORS_ORIGINS` (dev: `http://localhost:4200,http://localhost:4300`, porte provvisorie delle app Angular), `COOKIE_SECRET` (≥ 32), `RATE_LIMIT_MAX`, `RATE_LIMIT_WINDOW`, `MEDUSA_URL` (default `http://localhost:9000`), `MEDUSA_TIMEOUT_MS` (5000), `MEDUSA_PAYMENT_PROVIDER_ID` (`pp_stripe_stripe`), `MEDUSA_PUBLISHABLE_KEY`, `INTERNAL_API_SECRET` (≥ 32), `WEBHOOK_SECRET` (≥ 32), `DATABASE_URL`, `JWT_SECRET` (≥ 32), `ACCESS_TOKEN_TTL_SECONDS`, `REFRESH_TOKEN_TTL_DAYS`, `AUTH_COOKIE_DOMAIN`, `AUTH_COOKIE_SECURE`, `AUTH_LOGIN_RATE_MAX`, `AUTH_EMAIL_RATE_MAX`, `PRIVATE_APP_URL`, `PRIVACY_POLICY_VERSION`, `ADMIN_NOTIFY_EMAILS` (facoltativa), `UPLOAD_DIR` (default `uploads`; in produzione percorso assoluto), `UPLOAD_MAX_BYTES` (25 MB), `PUBLIC_UPLOADS_URL` (default `http://localhost:3000/uploads`; in produzione l'URL di Caddy, non localhost), `EMAIL_PROVIDER` (`console`|`brevo`|`resend`, default `console`), `EMAIL_FROM` (obbligatoria con brevo/resend), `EMAIL_FROM_NAME` (`ITI TECHNOLOGY`), `EMAIL_REPLY_TO` (facoltativa), `BREVO_API_KEY` / `RESEND_API_KEY` (quella del provider scelto), `EMAIL_OUTBOX_DIR` (solo dev); per i test: `TEST_DATABASE_URL` (opzionale); solo per il seed: `SEED_ADMIN_EMAIL`, `SEED_ADMIN_PASSWORD`, `SEED_ADMIN_FIRST_NAME`, `SEED_ADMIN_LAST_NAME` (opzionali).
**Autenticazione** (`src/plugins/auth.ts`, `src/services/auth.ts`, `src/routes/public/auth.ts`, `src/routes/private/me.ts`):
| Route | Body | Risposta | Note |
|---|---|---|---|
| `POST /auth/register` | `email, password (10-128), firstName, lastName, accountType? (customer\|company\|installer), companyName?, vatNumber?, phone?, acceptPrivacy: true, acceptMarketing?` | **sempre 202** `{ message }` | limite email |
| `POST /auth/login` | `email, password` | 200 `{ accessToken, expiresIn, user: UserDto }` + cookie refresh | limite login; errore unico 401 `INVALID_CREDENTIALS` |
| `POST /auth/refresh` | — (cookie `iti_rt`) | 200 come login + nuovo cookie | 30/min |
| `POST /auth/logout` | — (cookie) | 204, cookie cancellato | |
| `POST /auth/forgot-password` | `email` | **sempre 202** `{ message }` | limite email |
| `POST /auth/reset-password` | `token, password` | 200 / 400 `INVALID_TOKEN` | limite login |
| `POST /auth/verify-email` | `token` | 200 / 400 `INVALID_TOKEN` | limite login |
| `POST /auth/verify-email/resend` | — (autenticato) | 202 | limite email |
| `POST /auth/change-password` | `currentPassword, newPassword (10-128)` (autenticato) | 200 come login + nuovo cookie | limite login. Password attuale errata → 400 `INVALID_CURRENT_PASSWORD` (`details` su `body.currentPassword`); uguale all'attuale → 400. Chiude **tutte** le sessioni e ne apre una nuova su questo dispositivo: sta sotto `/auth` perché il cookie (path `/auth`) deve arrivare qui |
| `GET /me` | — (autenticato) | 200 `UserDto` (letto dal DB) | |
| `PATCH /me` | `firstName?, lastName?, companyName? \| null, vatNumber? \| null` (autenticato) | 200 `UserDto` | email e ruolo non modificabili (campi ignorati). Dati aziendali: privati e admin → 400; azienda → ragione sociale e P.IVA non svuotabili; installatore → ragione sociale obbligatoria. Il nome del customer Medusa **non** viene aggiornato |
Flusso:
1. **Registrazione**: ruolo `customer`; `company` se `accountType: "company"` oppure se arrivano ragione sociale + P.IVA (con `accountType: "company"` sono obbligatorie); `installer` (ragione sociale obbligatoria) crea anche `installer_profiles` in `pending`. **`admin` non è mai assegnabile**: non è tra i valori di `accountType`, e un eventuale campo `role` viene scartato. Salva il consenso `privacy` (più `marketing` se richiesto) in `consents` con `PRIVACY_POLICY_VERSION`, poi invia l'email di verifica (link `PRIVATE_APP_URL/verify-email?token=…`, 48 h). **Anti-enumerazione**: se l'email esiste già, la risposta è identica (202) e il titolare riceve un'email di avviso ("register-existing"). Non fa login automatico: l'utente accede con `/auth/login`. Il login è consentito anche con email non verificata (`user.emailVerified` dice al frontend se mostrare il banner).
2. **Login**: credenziali errate, utente inesistente o disabilitato danno lo stesso 401. Per un utente inesistente viene comunque verificato un hash argon2 fittizio, così i tempi di risposta sono uniformi. In caso di successo: access token nel body + refresh token in cookie (nuova *family*).
3. **Access token**: JWT HS256 (`JWT_SECRET`), durata `ACCESS_TOKEN_TTL_SECONDS` (900 s). Payload solo `{ sub: userId, role }`. Il frontend lo tiene **in memoria** e lo invia come `Authorization: Bearer …`. Ruolo o disabilitazione cambiati in DB valgono al refresh successivo (≤ 15 min).
4. **Refresh token**: 256 bit casuali (base64url) nel cookie **`iti_rt`**: `HttpOnly`, `Secure` (`AUTH_COOKIE_SECURE`), `SameSite=Lax`, `Path=/auth`, `Domain=AUTH_COOKIE_DOMAIN` (dominio padre in produzione, per condividerlo tra sito e area privata; vuoto in dev), scadenza `REFRESH_TOKEN_TTL_DAYS` (30). In DB solo SHA-256, con `family_id`, user agent e IP. Insieme viene impostato il cookie indizio **`iti_session=1`** (non httpOnly, `Path=/`, stessi dominio e scadenza, cancellato con `iti_rt`): dice ai frontend se vale la pena chiamare `/auth/refresh` (vedi `apps/public`, "Sessione nell'app pubblica").
5. **Rotazione**: ogni `/auth/refresh` revoca il token presentato e ne emette uno nuovo nella stessa family, con revoca condizionata in transazione: due refresh concorrenti con lo stesso token contano come riuso. **Riuso** di un token già revocato → **revoca dell'intera family** (warning nei log) e 401. Le altre sessioni dell'utente, cioè family diverse, restano valide. Utente disabilitato → family revocata. ⚠️ Il frontend deve **serializzare i refresh** (una sola richiesta alla volta, anche tra tab), altrimenti due refresh paralleli fanno scattare la revoca.
6. **Logout**: revoca la family del cookie (solo quel dispositivo) e cancella il cookie; 204 anche senza cookie.
7. **Reset password**: token monouso 1 h (i precedenti non usati vengono eliminati), link `PRIVATE_APP_URL/reset-password?token=…`. Il reset aggiorna la password, segna l'email come verificata (il link dimostra il possesso) e **revoca tutte le sessioni** dell'utente.
8. **Verifica email**: token monouso 48 h → `email_verified_at`, poi claim degli ordini guest Medusa (vedi "Client Medusa").
- **Decorator**: `app.authenticate` (401 `UNAUTHORIZED` con messaggio in italiano) e `app.requireRole(...ruoli)` (autentica se serve, poi 403 `FORBIDDEN`). Uso: `{ onRequest: [app.authenticate] }` oppure `{ onRequest: [app.requireRole("admin")] }`. Nell'handler: `request.user.sub` / `request.user.role`.
- **Rate limit dedicati** per IP: `AUTH_LOGIN_RATE_MAX` al minuto (default 5) su login, reset-password e verify-email; `AUTH_EMAIL_RATE_MAX` ogni 15 minuti (default 3) su register, forgot-password e resend.
- **Email**: `app.email.send({ to, template, data })`, dettagli nella sezione "Email transazionali".
- **Segreti**: in DB solo hash (argon2id per le password, SHA-256 per i token); nei log sono redatti `authorization`, `cookie` e `set-cookie`, e i token viaggiano solo nel body o nei cookie, mai negli URL dell'API.
- **Test** (`test/auth.test.ts`, 23 casi): registrazione (normalizzazione email, ruoli, admin rifiutato, company/installer, consenso, email duplicata), login ok/ko con lo stesso errore, attributi del cookie, `/me`, rotazione, riuso → family revocata (altre family intatte), utente disabilitato, logout, `requireRole`, forgot/reset (risposta uguale, monouso, sessioni chiuse), verify-email monouso, **nessun segreto nel dump DB né nei log catturati**, 429 sul login.
- **DB dei test**: `iti_app_test` (stesso utente `iti_app`, stesse estensioni), creato dall'init del container (per i volumi esistenti va creato una volta a mano: già fatto in dev). `test/global-setup.ts` applica le migrazioni; `truncateAll()` svuota le tabelle prima di ogni test; i file di test girano in sequenza. L'URL è `TEST_DATABASE_URL` oppure `DATABASE_URL` + `_test`, e il codice **rifiuta** DB il cui nome non finisce con `_test`. Helper in `test/helpers.ts` (`buildTestApp(overrides, setup)`, `testConfig`, `cookieFrom`, `tokenFromEmail`, `LogCapture`).
**Client Medusa e sincronizzazione account** (`src/services/medusa-client.ts`, `src/plugins/medusa-client.ts`, `src/services/medusa-sync.ts`):
- **`app.medusa`** = `MedusaClient` con due client sullo stesso trasporto (`fetch` + `AbortSignal.timeout(MEDUSA_TIMEOUT_MS)`, JSON, risposta validata con uno schema zod):
- `app.medusa.store.request(path, schema, { method?, body?, query?, headers? })` → header `x-publishable-api-key` (per il proxy shop, prompt 13);
- `app.medusa.internal` → header `x-internal-secret`; metodi tipizzati `upsertCustomer`, `setInstallerGroup`, `claimOrders` + `request` generico per le altre route `/internal/*`.
- **Errori uniformi**: `MedusaError { kind, status?, type? }` con `kind` = `unavailable` (rete, timeout, 5xx: si può ritentare), `rejected` (4xx, `type` Medusa es. `not_found`), `invalid_response` (2xx con formato inatteso). Se una route non lo gestisce, l'error handler risponde **503 `SHOP_UNAVAILABLE`** (unavailable) o **502 `BAD_GATEWAY`**, con messaggio generico; il dettaglio va solo nei log.
- Funzioni di sincronizzazione (`medusa-sync.ts`, prendono `app` come `{ db, medusa, log }`):
- **`ensureMedusaCustomer(app, user)`** → id del customer Medusa: se `users.medusa_customer_id` manca chiama `POST /internal/customers` (upsert per email normalizzata, idempotente) e lo salva (update solo se ancora null). **Lancia** `MedusaError`: le operazioni shop (prompt 13) la chiamano prima di ogni azione legata al customer.
- **`claimGuestOrders(app, user)`** → `POST /internal/orders/claim`; **non fa nulla se l'email non è verificata** (il claim lato Medusa non richiede prova di possesso). Idempotente.
- **`syncInstallerGroup(app, user)`** → membro del gruppo installatori solo se ruolo `installer` + profilo `approved` + non disabilitato, altrimenti `remove`. Lancia `MedusaError` (per il prompt 20: approvazione/rifiuto/revoca).
- **Mai bloccante nei flussi di autenticazione**: `app.syncInBackground(what, userId, fn)` avvia la sincronizzazione senza attenderla (errori → `warn` "sincronizzazione Medusa non riuscita … verrà ritentata"); le operazioni pendenti vengono attese alla chiusura del server (`app.flushSyncs()`, usato anche dai test).
- registrazione → `ensureMedusaCustomer` (con Medusa spento la registrazione riesce, l'id resta null);
- login → `ensureMedusaCustomer`, oppure `claimGuestOrders` se l'email è verificata: recupera customer mancanti e claim falliti, e collega anche ordini guest fatti dopo la registrazione senza login;
- verifica email e reset password che verifica l'email → `claimGuestOrders`.
- **Test** (`test/medusa.test.ts`, 13 casi) con **Medusa mockato** (`test/medusa-mock.ts`: route `/internal/*` in memoria, `down` = errore di rete, `failStatus`). `buildTestApp` inietta sempre il mock (`buildApp(config, { medusaFetch })`): i test non fanno mai chiamate di rete.
- **Verificato end-to-end** con Medusa reale: ordine guest (draft convertito, email maiuscola) → registrazione → customer `has_account=true` creato e id salvato in `users` → verifica email → ordine passato al customer.
**Proxy shop** (`src/routes/public/shop.ts`, `src/services/shop.ts`, `src/services/cart-cookie.ts`, plugin `app.shop`):
| Route | Body / query | Risposta |
|---|---|---|
| `GET /shop/products` | `?limit (1-50, 12)&offset&category=<handle>&q=` | `Paginated<ShopProductSummaryDto>` (`fromPrice` + `fromOriginalPrice`, il prezzo di listino pubblico della stessa variante: prompt 31) |
| `GET /shop/products/:handle` | — | `ShopProductDetailDto` (varianti con `id`, `price`, `originalPrice`, `inStock`) / 404 `PRODUCT_NOT_FOUND` |
| `GET /shop/categories` | — | `ShopCategoryDto[]` |
| `POST /shop/cart` | — | 200 carrello aperto / **201** nuovo (imposta il cookie) |
| `GET /shop/cart` | — | `ShopCartDto` / 404 `CART_NOT_FOUND` |
| `POST /shop/cart/items` | `{ variantId, quantity (1-100, 1) }` | carrello (lo crea se manca) / 409 `OUT_OF_STOCK` |
| `PATCH /shop/cart/items/:id` | `{ quantity }` | carrello / 404 `CART_ITEM_NOT_FOUND` |
| `DELETE /shop/cart/items/:id` | — | carrello |
| `POST /shop/cart/address` | `{ email? (obbligatoria se anonimo), shippingAddress, billingAddress?, invoice? \| null, acceptPrivacy: true, acceptMarketing? }` | carrello / 400 `EMAIL_REQUIRED` |
| `GET /shop/cart/shipping-options` | — | `ShopShippingOptionDto[]` |
| `POST /shop/cart/shipping` | `{ optionId }` | carrello (`readyForPayment`) |
| `POST /shop/cart/payment` | — | `{ provider: "stripe", clientSecret, amount }` / 409 `CART_NOT_READY` (20/min) |
| `POST /shop/cart/complete` | — | `ShopOrderDto` (numero ordine), cookie cancellato / 402 `PAYMENT_NOT_COMPLETED` (20/min) |
- **DTO** in `@iti/shared` (`Shop*Dto`): importi in **centesimi IVA inclusa** (Medusa usa euro, conversione con `toCents`), prodotti e categorie identificati per **handle**. Gli unici id Medusa esposti sono quelli che il frontend rimanda (variante, riga, opzione di spedizione): mai id di carrello, customer, regione o payment collection, mai chiavi. Il `clientSecret` serve a Stripe.js.
- **Autenticazione facoltativa** (`app.optionalAuthenticate`): senza `Authorization` la richiesta è anonima. Con un token non valido o scaduto la risposta è **401**, così il frontend fa il refresh invece di ricevere in silenzio i prezzi da anonimo. Per un utente autenticato `viewerFor` chiama `ensureMedusaCustomer` (con Medusa giù → 503).
- **Prezzi installatore**: valgono per ruolo `installer` con profilo `approved` e account non disabilitato. In quel caso i prezzi del catalogo vengono da `POST /internal/prices` sul suo customer (`priceType: "installer"`, `originalPrice` / `fromOriginalPrice` = prezzo pubblico); per tutti gli altri prezzi standard. Il carrello lo riprezza Medusa da sé, perché il customer è nel gruppo.
- **Cache 60 s in memoria** (`TtlCache`): liste e dettaglio prodotti con prezzi standard (condivise tra tutti), categorie, prezzi installatore per customer. Le risposte personalizzate non finiscono mai nella cache condivisa. `app.shop.clearCache()` la svuota (usata dai test; in futuro per l'invalidazione).
- **Cookie `cart_id`**: id Medusa **firmato** (`COOKIE_SECRET`), `HttpOnly`, `SameSite=Lax`, `Secure`/`Domain` come il refresh token, **`Path=/`** (deve arrivare anche a `/auth/login`), 30 giorni. Un cookie manomesso viene ignorato. Se il carrello non esiste, è completato o non è accessibile, il cookie viene cancellato.
- **Proprietà del carrello** (`resolveCart`): è "di un account" il carrello il cui `customer_id` compare in `users.medusa_customer_id`. La Store API non espone `has_account`, quindi la fonte è la mappatura in `users`.
- Anonimo → solo carrelli guest: dopo un logout il carrello dell'account non è più visibile.
- Utente autenticato → il proprio carrello, oppure un carrello guest, che gli viene **associato** (`/internal/carts/:id/customer` = `transferCartCustomer`: email dell'account e riprezzo). Il carrello di un altro account non è mai accessibile (404, nessun trasferimento).
- **Login con un carrello guest aperto**: `/auth/login` legge `cart_id` e associa subito il carrello. Se fallisce viene solo loggato: il login riesce lo stesso e l'associazione avviene alla prima chiamata `/shop/cart*`. Il **logout** cancella `cart_id`: in Medusa il carrello resta legato all'account, ma sul dispositivo si riparte con un carrello nuovo.
- **Dati per la fattura** (prompt 26): `invoice = { companyName, vatNumber, fiscalCode?, sdiCode?, pec? }`. La P.IVA viene normalizzata (senza spazi né "IT") e se ne verifica la cifra di controllo; SDI di 7 caratteri; codici in maiuscolo, PEC in minuscolo. Finisce in **`metadata.invoice` del carrello Medusa**, che `completeCartWorkflow` copia nell'ordine (`metadata: cart.metadata`, verificato nel sorgente di core-flows 2.21). `null` o assente toglie i dati. Il modulo cart **sostituisce** i metadata invece di fonderli, quindi l'API reinvia le altre chiavi. Nel DTO: `ShopCartDto.invoice` (`ShopInvoiceDto`). Validazioni condivise in `@iti/shared` → `validation.ts` (`isValidItalianVat`, `normalizeVatNumber`, `FISCAL_CODE_PATTERN`, `SDI_CODE_PATTERN`).
- **Consenso privacy del checkout**: `acceptPrivacy: true` è obbligatorio su `/shop/cart/address`, dove arrivano i dati personali. `recordConsentsOnce` (origine `checkout`) salva il consenso privacy solo se non ne esiste già uno attivo con stessa email (o utente), versione e origine, e quello marketing solo se non ne esiste già uno attivo: reinviare l'indirizzo non crea duplicati.
- **Checkout guest**: basta l'email, che va a Medusa via `/internal/carts/:id/guest` (vedi sezione Medusa). Per un utente autenticato vale sempre l'email dell'account e quella nel body viene ignorata. Indirizzi solo in Italia (`countryCode: "it"`, CAP di 5 cifre); senza indirizzo di fatturazione si usa quello di spedizione.
- **Sequenza di pagamento (Medusa v2)**:
1. `POST /store/payment-collections { cart_id }` → crea la collection, o restituisce quella esistente.
2. `POST /store/payment-collections/:id/payment-sessions { provider_id }` → ricrea la sessione con l'importo aggiornato (da richiamare se il carrello cambia). Il `client_secret` è in `payment_sessions[].data.client_secret`.
3. Il frontend conferma il pagamento con Stripe.js.
4. `POST /store/carts/:id/complete` → `{ type: "order", order }` oppure `{ type: "cart", error }`. Con `capture: true` Stripe cattura all'autorizzazione.
- **Errori Medusa**: i 4xx diventano errori in italiano, senza dettagli interni: 404 → `NOT_FOUND`, giacenza insufficiente → 409 `OUT_OF_STOCK`, altro → 400 `SHOP_INVALID_REQUEST`. Il messaggio originale va solo nel log (`info`). 5xx e timeout → 503 `SHOP_UNAVAILABLE`.
- **Test**: `test/shop.test.ts` (15 casi, compresi consenso obbligatorio, fattura nei metadata e consensi senza duplicati) con la Store API finta di `test/medusa-mock.ts`. Test manuale completo, Stripe compreso, in `apps/api/http/shop.http` (HTTP Client WebStorm, ambiente `dev`; segreti in `http-client.private.env.json`, modello nel file `.example`). Lì il PaymentIntent (`pm_card_visa`) viene confermato direttamente sulle API Stripe con la chiave segreta di test, al posto di Stripe.js.
- **Verificato con Medusa reale** (senza pagamento, chiave Stripe segnaposto):
- catalogo: 5.490 € → `549000`; installatore approvato → `465000` con `originalPrice 549000`; anonimo e cache restano a 5.490;
- carrello guest → login → riprezzato a 4.650, con l'email dell'account;
- cookie del carrello di un account usato da anonimo → 404;
- guest che usa l'email dell'installatore → resta guest, a 5.490;
- spedizione 15 € → totale 5.505 € con IVA 992,70;
- payment session → 503 (errore del provider Stripe: chiave non valida).
- ⚠️ **Pagamento reale e complete non ancora verificati**: serve una chiave Stripe di test in `medusa/apps/backend/.env`, poi va eseguito `shop.http`.
**Storage e documenti** (`src/services/storage.ts`, `src/services/documents.ts`, plugin `src/plugins/storage.ts`, route `src/routes/public/documents.ts` e `src/routes/admin/documents.ts`):
- **`app.storage`** (`StorageService`: `put` / `get` / `delete` / `publicUrl`). Implementazione `LocalDiskStorage` sotto `UPLOAD_DIR`; per passare a S3/MinIO basta un'altra implementazione, passata in `buildApp(config, { storage })`. Le route non conoscono il disco.
- **`public/`**: immagini del blog e foto. In produzione la serve **Caddy** su `PUBLIC_UPLOADS_URL` (Caddy monta **solo** la sottocartella `public/` del volume, con `subpath`). Fuori dalla produzione la serve l'API su `/uploads/` (`@fastify/static`, radice `UPLOAD_DIR/public`, niente dotfile né indici).
- **`private/`**: documenti. Non è mai servita staticamente, solo in stream da `GET /documents/:id/download` dopo il controllo dei permessi. In DB `documents.file_path` è la chiave relativa a `private/` e non esce mai nelle risposte.
- **Chiavi** generate dal server: `<documents|images>/<anno>/<mese>/<uuid><ext>`. Il nome originale del file non entra mai nel percorso.
- **Path traversal**, doppio controllo: formato della chiave (`[a-z0-9._-]` per segmento, niente `.` o `..`, niente percorsi assoluti), poi il percorso risolto deve stare dentro la radice dell'area. Altrimenti `InvalidStorageKeyError`.
- Scrittura con flag `wx`, che non sovrascrive mai un file esistente.
- **Upload** (`@fastify/multipart`, 1 file per richiesta, `UPLOAD_MAX_BYTES`, max 10 campi da 10 KB), helper `receiveUpload`:
1. estensione non ammessa → **415 `UNSUPPORTED_FILE_TYPE`**, senza scrivere nulla;
2. scrittura in streaming;
3. oltre il limite → **413 `FILE_TOO_LARGE`**;
4. **magic bytes** del contenuto e MIME dichiarato devono indicare lo stesso tipo dell'estensione (oppure MIME `application/octet-stream`), altrimenti 415. Anche un file vuoto è rifiutato;
5. nessun file → 400 `FILE_REQUIRED`; più file → 400 `TOO_MANY_FILES`.
- Ogni rifiuto cancella il file già scritto. In DB si salva il **MIME canonico**, non quello dichiarato dal client.
| Tipo | Estensioni | MIME salvato | Riconoscimento |
|---|---|---|---|
| JPEG / PNG / WebP / GIF | `.jpg .jpeg` / `.png` / `.webp` / `.gif` | `image/jpeg` … | firma binaria |
| PDF | `.pdf` | `application/pdf` | `%PDF-` |
| ZIP | `.zip` | `application/zip` | `PK\x03\x04` / `PK\x05\x06` |
| DWG | `.dwg` | `image/vnd.dwg` | `AC10nn` |
| DXF | `.dxf` | `image/vnd.dxf` | `0` + `SECTION` (ASCII), `999` (commento) o firma binaria |
SVG e HTML non sono mai ammessi (rischio XSS). Le immagini pubbliche accettano solo JPEG, PNG, WebP e GIF.
- **Visibilità** (`visibleLevels`):
| Utente | Livelli visibili |
|---|---|
| anonimo o disabilitato | `public` |
| `customer`, `company` | `public`, `customer` |
| `installer` **approvato** | `public`, `customer`, `installer` |
| `installer` pending/rejected | come un cliente |
| `admin` | tutti, incluso `admin` |
Installatore approvato significa `installer_profiles.status = approved`, letto dal DB a ogni richiesta: chi si registra come installatore non vede i documenti tecnici finché non viene approvato.
| Route | Accesso | Note |
|---|---|---|
| `GET /documents` | auth facoltativa | `?category&productRef&limit (≤100)&offset` → `Paginated<DocumentDto>` dei livelli visibili |
| `GET /documents/:id/download` | auth facoltativa | stream con `Content-Disposition: attachment` (titolo + estensione, `filename*` UTF-8), `Cache-Control` `private, no-store` (`public, max-age=300` per i pubblici). Livello non visibile: **401** se anonimo, **403** se autenticato. 404 se il documento o il file mancano |
| `POST /admin/documents` | admin | multipart: `file` + `title`, `visibility` (default `admin`), `category?`, `productRef?` (handle Medusa) → 201 `DocumentDto` |
| `GET /admin/documents` | admin | `?visibility&category&q (titolo)&limit&offset` |
| `PATCH /admin/documents/:id` | admin | JSON con i soli metadati (per sostituire il file: nuovo upload + DELETE) |
| `DELETE /admin/documents/:id` | admin | 204: cancella la riga e il file (i preventivi che lo usavano restano, con `pdf_document_id` null) |
| `POST /admin/uploads/image` | admin | multipart `file` → 201 `{ url, path, mimeType, sizeBytes }` nell'area public |
- **Area admin**: `src/routes/admin/index.ts` registra tutte le route `/admin/*` dietro un unico hook `requireRole("admin")`. Un secondo hook rilegge l'utente dal DB (prompt 34): un admin disabilitato o declassato perde l'accesso subito, non alla scadenza del token. Le route dei prompt successivi vanno registrate lì. Le route di upload hanno `UPLOAD_RATE_LIMIT` (30/min).
- **Test** (`test/documents.test.ts`, 14 casi, storage in una cartella temporanea `TEST_UPLOAD_DIR`; helper `multipartBody`):
- installatore approvato scarica, customer → 403, installatore pending → 403, anonimo → 401; elenco per ruolo;
- tutti i tipi ammessi;
- rifiuti: exe, svg, html, PDF finto, JPEG che in realtà è un PNG, MIME incoerente, file senza estensione, vuoto, troppo grande, due file, metadati non validi. Dopo ogni rifiuto nessun file resta su disco;
- path traversal nel nome file e nelle chiavi;
- file privati non raggiungibili via `/uploads` (anche con `..` codificati), immagine pubblica servita;
- PATCH, DELETE con file cancellato.
**Blog** (`src/routes/public/blog.ts`, `src/routes/admin/blog.ts`, `src/services/blog.ts`):
| Route | Accesso | Note |
|---|---|---|
| `GET /blog/posts` | pubblico | `?category=<slug>&q=&limit (≤50, 10)&offset` → `Paginated<BlogPostSummaryDto>`; con `q` ordinamento per `ts_rank`, altrimenti per `published_at` decrescente |
| `GET /blog/posts/:slug` | pubblico | `BlogPostDetailDto` con `contentMd` **e** `contentHtml` sanitizzato / 404 `POST_NOT_FOUND` |
| `GET /blog/categories` | pubblico | `BlogCategoryDto[]` per nome |
| `GET /sitemap-data` | pubblico | `{ posts: [{ slug, publishedAt, updatedAt }], categories: [{ slug, updatedAt }] }`, `Cache-Control: public, max-age=300` (per la sitemap SSR) |
| `GET /admin/blog/posts` | admin | tutti gli stati, `?status&q&limit&offset`, `BlogPostAdminDto` |
| `GET/PATCH/DELETE /admin/blog/posts/:id`, `POST /admin/blog/posts` | admin | CRUD; l'autore è l'admin che crea |
| `POST /admin/blog/preview` | admin | `{ markdown }` → `{ html }` sanitizzato (anteprima live dell'editor) |
| `GET/POST /admin/blog/categories`, `PATCH/DELETE /admin/blog/categories/:id` | admin | eliminando una categoria i post restano, senza categoria |
- **Visibilità pubblica**: solo `status = published` **e** `published_at <= now()`. Una data futura programma il post: non compare nella lista, nel dettaglio né nella sitemap finché la data non arriva. Bozze → 404.
- **Stato**: pubblicando senza data → `published_at = now()`; una data esplicita viene rispettata. Tornare a `draft` conserva la data.
- **Slug**:
- generato dal titolo con `slugify` (minuscolo, senza accenti, `[a-z0-9-]`, max 80) e reso univoco con suffisso `-2`, `-3`… Il vincolo unique in DB è la garanzia finale: in concorrenza si ritenta.
- Cambiare il titolo **non** cambia lo slug (URL stabili); si cambia solo inviando `slug` esplicito. Uno slug esplicito già usato → 409 `SLUG_TAKEN`.
- Le categorie seguono le stesse regole, generate dal nome.
- **Ricerca**: `search_vector @@ websearch_to_tsquery('italian', unaccent(q))`, rank `ts_rank`. La sintassi websearch supporta `"frase"` e `-escluso`; una query vuota per tsquery non dà errore e restituisce 0 risultati. Esempio: `pompa calore` trova "Pompe di calore: guida" (stem `pomp` / `calor`); `perche` trova "Perché…".
- **Markdown**: `marked` (istanza dedicata, GFM) → `sanitize-html` con whitelist.
- Ammessi: titoli, paragrafi, liste, citazioni, codice (classi `language-*`), tabelle, `a` e `img`, `figure`.
- Schemi ammessi: link `http`, `https`, `mailto`, `tel`; immagini solo `http`/`https` (niente `data:`), niente URL protocol-relative.
- Rimossi: `script`, `iframe`, `style`, attributi `on*`, `style` inline, `javascript:`.
- I link esterni ricevono `rel="noopener noreferrer" target="_blank"`, le immagini `loading="lazy"`.
- L'HTML si genera a ogni lettura (nessuna cache) e `content_md` resta il sorgente.
- **Copertina**: `coverPath` è il `path` restituito da `POST /admin/uploads/image` (deve iniziare con `images/`); nel DTO diventa `coverUrl` = `PUBLIC_UPLOADS_URL/<path>`.
- **Test** (`test/blog.test.ts`, 14 casi):
- ricerca con stemming, unaccent, rank, sintassi websearch;
- bozze e post programmati invisibili (lista, dettaglio, ricerca, sitemap); filtro categoria;
- sanitizzazione (script, onerror, `javascript:`, iframe, style, `data:`) su dettaglio e preview;
- slug univoci e stabili, 409; PATCH parziale che non tocca stato e contenuto;
- permessi admin (customer 403, anonimo 401); categoria inesistente; copertina fuori da `images/`.
**Configuratore** (`src/configurator/`, route `src/routes/public/configurator.ts`, tipi in `@iti/shared` → `configurator.ts`):
- Motore **deterministico a regole, nessuna AI**. Tre file con ruoli separati:
| File | Contiene | Come si modifica |
|---|---|---|
| `definition.ts` | wizard: step, domande (`single`, `multi`, `number` con min/max/step/unità, `province`), opzioni, testi, condizioni `visibleIf` (branching) | testi ed etichette liberamente; cambiando **id** o **valori** delle opzioni vanno aggiornati `engine.ts` e i test. Aggiornare `DEFINITION_VERSION` |
| `rules.ts` | **tutti i numeri**: prezzo energia e RID, produttività FV per zona, 107 province → zona, orientamento, autoconsumo, accumulo, costi €/kWp e €/kWh, parametri addolcitore (durezza di default e obiettivo, litri/persona, capacità resina) e taglie, isolamento → W/m² e kWh/m², fattori climatici, taglie e costi pompe di calore, SCOP, combustibili, parametri aziende, testi incentivi e avvertenza | cambiare una soglia richiede **solo** di modificare questo file. Poi aggiornare `RULES_VERSION` e riallineare i casi tabellari in `test/configurator.test.ts`, che sono valori calcolati a mano e falliscono di proposito se le regole cambiano |
| `engine.ts` | funzioni **pure** (niente DB né rete): `validateAnswers(definition, answers)`, `evaluate(answers)` → `evaluateWater` / `evaluatePv` / `evaluateHeatPump` / `evaluateBusiness` | logica di calcolo |
- **Percorsi** (domanda `percorso`):
- `acqua`: persone, durezza (se ignota 30 °f), tipo di abitazione → addolcitore con taglia per litri di resina, filtrazione se ≤ 15 °f, sopralluogo oltre 40 L;
- `fotovoltaico`: provincia, consumo in kWh o bolletta mensile, orientamento, superficie del tetto (facoltativa, limita la taglia), accumulo, uso diurno → kWp, batteria, autoconsumo, risparmio;
- `pompa_calore`: provincia, m², isolamento, combustibile attuale, terminali, acqua calda sanitaria → kW (taglia commerciale), confronto dei costi annui, sopralluogo oltre 16 kW;
- `azienda`: provincia, settore, interessi (multipla) e consumo kWh se c'è il fotovoltaico → consulenza dedicata più un fotovoltaico indicativo.
- **Validazione** (`validateAnswers`): le condizioni `visibleIf` si valutano in ordine, solo su domande precedenti (un test lo verifica). Errori con `path: "answers.<id>"`:
- domanda inesistente;
- **risposta a una domanda non visibile** per le scelte fatte (incoerente);
- obbligatoria mancante;
- tipo, opzione, range, passo o provincia non validi; scelta multipla vuota o con ripetizioni.
- Le route rispondono **400 `VALIDATION_ERROR`** con `details`.
- **Output** (`ConfiguratorResult`):
- `solution { code, title, description }`, `size { value, unit, label }`, `products[{ handle Medusa, quantity, note }]` (da `PV_PRODUCTS`, `SOFTENER_SIZES`, `HEAT_PUMP_SIZES` in rules.ts);
- `estimates { investmentCents (arrotondato alle centinaia di euro), annualSavingsCents, paybackYears }`;
- `details[{ label, value }]` leggibili, `warnings`, `incentivesNote` (testo statico per percorso), `disclaimer` "stima indicativa", `rulesVersion`.
| Route | Note |
|---|---|
| `GET /configurator/definition` | definizione + province, `Cache-Control: public, max-age=300` |
| `POST /configurator/evaluate` | `{ answers }` → risultato, senza salvare (60/min) |
| `POST /configurator/submit` | `{ answers, contact { name, email, phone? }, message?, acceptPrivacy: true, acceptMarketing? }` → **201 `{ id, result }`** (5 ogni 15 min per IP). Dettagli sotto |
- Cosa fa `submit`:
- consenso privacy mancante o `false` → 400 e niente viene salvato;
- salva `quote_requests`: `configurator_input = { definitionVersion, answers, message? }`, `configurator_output` = risultato, `province` dalle risposte, `user_id` se autenticato e attivo;
- salva i consensi (`source: "configurator"`);
- invia `quote-request-admin` agli admin (`ADMIN_NOTIFY_EMAILS` o utenti admin, helper `services/notifications.ts`) con link `PRIVATE_APP_URL/admin/richieste/<id>` (rotta del pannello da confermare nel prompt 32) e `quote-request-received` all'utente.
- **Test** (`test/configurator.test.ts`, 25 casi):
- **13 casi tabellari** (3-4 per percorso, conti in commento);
- validazione: obbligatorie, incoerenze di branching, tipi, range, province; coerenza della definizione;
- route: definition, evaluate con 400 dettagliato, submit senza consenso → 400, submit completo (DB, consensi, email), utente autenticato collegato.
**Preventivi, ticket e ordini del cliente** (route `src/routes/private/{quotes,orders,tickets}.ts`, `src/routes/admin/{quotes,tickets}.ts`, servizi `quotes.ts`, `tickets.ts`, `current-user.ts`, schemi condivisi `src/routes/private/schemas.ts`):
| Route cliente (autenticato) | Note |
|---|---|
| `GET /me/quotes` | preventivi propri **esclusi i draft**, `?limit&offset` → `Paginated<QuoteDto>` |
| `GET /me/quotes/:id` | 404 se di un altro utente, in bozza o inesistente |
| `GET /me/quotes/:id/pdf` | PDF allegato (`Preventivo <numero>.pdf`, `private, no-store`) |
| `POST /me/quotes/:id/accept` · `/reject` | solo se `sent` e non scaduto (riga bloccata `FOR UPDATE`): altrimenti 409 `QUOTE_NOT_PENDING` / `QUOTE_EXPIRED`; notifica allo staff |
| `GET /me/orders` | ordini Medusa del proprio customer (`/internal/customers/:id/orders`, crea il customer se manca) → `Paginated<MyOrderDto>` in centesimi, senza id Medusa |
| `GET /me/orders/:number` | dettaglio per numero (`display_id`): proprietà verificata con il filtro `display_id` sugli ordini del proprio customer, poi `GET /internal/orders/:id` → `MyOrderDetailDto` (righe "prodotto - variante" con miniatura, totali con sconto, `shippingAddress` in righe, `shippingMethods`, `tracking[{ number, url }]`). Ordine altrui o inesistente → 404 `ORDER_NOT_FOUND`; numero non valido → 400; Medusa giù → 503 |
| `GET /me/tickets` · `POST /me/tickets` | elenco (`?status`) · nuovo ticket `{ subject, body, priority? low\|normal\|high }` col primo messaggio, notifica allo staff (10/ora) |
| `GET /me/tickets/:id` · `POST /me/tickets/:id/messages` | `TicketDetailDto` con messaggi (`fromStaff`); la risposta del cliente rimette il ticket `open` (anche se chiuso) e notifica lo staff (30/ora) |
| Route admin | Note |
|---|---|
| `GET /admin/quote-requests` | `?status&q (email/nome)&limit&offset` → `QuoteRequestAdminDto` |
| `GET /admin/quote-requests/:id` · `PATCH` `{ status }` | il dettaglio include i preventivi collegati |
| `POST /admin/quotes` | `{ title, amountCents, validUntil?, quoteRequestId? \| userId? }` → bozza numerata (dettagli sotto) |
| `GET /admin/quotes` (`?status&userId`) · `GET /admin/quotes/:id` | `QuoteAdminDto` (cliente, `pdfDocumentId`) |
| `PATCH /admin/quotes/:id` | titolo, importo, validità: **solo in bozza** (409 `QUOTE_NOT_DRAFT`) |
| `POST /admin/quotes/:id/pdf` | multipart, solo PDF; sostituisce l'eventuale PDF precedente (riga e file cancellati) |
| `POST /admin/quotes/:id/send` | draft/sent → `sent` (`sent_at` al primo invio), email `quote-sent` con link `PRIVATE_APP_URL/preventivi/<id>`, richiesta → `converted`. 409 se ha già una risposta o se la validità è scaduta |
| `GET /admin/tickets` (`?status&priority&q`) · `GET /admin/tickets/:id` · `PATCH` `{ status?, priority? }` | |
| `POST /admin/tickets/:id/messages` | `{ body, status? (default waiting_customer) }` → email `ticket-reply` al cliente con link `PRIVATE_APP_URL/assistenza/<id>` |
- **Ownership**: ogni query del cliente filtra per `user_id` dell'utente corrente. Risorsa altrui, in bozza o inesistente → **404** con lo stesso messaggio, così non si rivela che esiste. `requireActiveUser` rilegge l'utente dal DB (disabilitato → 401).
- **Numerazione** `${anno}-${seq a 4 cifre}` (anno nel fuso Europe/Rome): `nextQuoteNumber(tx, anno)` prende `pg_advisory_xact_lock(19001, anno)` nella transazione di creazione, poi `max(seq)+1`. Le creazioni concorrenti dello stesso anno si serializzano fino al commit, senza retry; il vincolo unique `year+seq` resta la garanzia finale. Testato con 15 creazioni in parallelo.
- **Destinatario del preventivo** (`quotes.user_id` è obbligatorio), nell'ordine:
1. `userId` esplicito;
2. l'utente collegato alla richiesta;
3. l'account con la stessa email della richiesta, che viene collegata.
- Senza account → 409 `CUSTOMER_ACCOUNT_REQUIRED`: il cliente deve registrarsi con quell'email. Un admin non può essere destinatario.
- **Stato `expired`**: un preventivo `sent` con `valid_until` passato viene **mostrato** come `expired`. In DB diventa `expired` quando il cliente prova a rispondere. Non c'è un job periodico.
- **PDF del preventivo**: è un documento con **visibilità `admin`** e categoria `preventivi`, quindi non compare a clienti e installatori in `/documents`. Il cliente lo scarica **solo** da `/me/quotes/:id/pdf`. Una visibilità `customer` lo renderebbe scaricabile da tutti i clienti. Nel DTO pubblico c'è `hasPdf` (era `pdfDocumentId`).
- **Richieste anonime → account**: alla **verifica dell'email**, al reset password che la verifica e a ogni login con email verificata, `linkAnonymousQuoteRequests` collega le `quote_requests` con `user_id` null e la stessa email. Non lo fa alla sola registrazione: chi si registra con l'indirizzo di un altro non eredita le sue richieste, stesso principio del claim degli ordini guest.
- **Notifiche allo staff**: `notifyStaff` (template `staff-notification`, destinatari come `ADMIN_NOTIFY_EMAILS`) per nuovo ticket, risposta del cliente, preventivo accettato o rifiutato. I link puntano a `PRIVATE_APP_URL/admin/{preventivi,ticket}/<id>` (rotte del pannello da confermare nel prompt 32).
- **Test** (`test/quotes.test.ts` 12, `test/tickets.test.ts` 4):
- ownership 404 su lettura, PDF e risposte; numerazione sequenziale e 15 creazioni concorrenti senza duplicati;
- invio con email, accettazione una sola volta, scadenza, PATCH solo in bozza;
- PDF sostituito e scaricabile solo dal proprietario (403 dal download generico);
- preventivo da richiesta (per email) e 409 senza account; collegamento delle richieste solo dopo la verifica dell'email;
- ordini da Medusa mock;
- ticket: ownership, risposta admin con email al cliente, riapertura.
**Installatori e PostGIS** (`src/routes/private/installer.ts`, `src/routes/admin/installers.ts`, `src/services/installers.ts`, `src/services/geo.ts`):
| Route | Accesso | Note |
|---|---|---|
| `GET /installer/profile` | ruolo `installer` (qualunque stato) | `InstallerProfileDto` (mai le note interne) |
| `PUT /installer/profile` | ruolo `installer` | `{ businessName, vatNumber, phone, address, location {lat,lng} \| null, serviceRadiusKm 1-300, certifications[] ≤20 }`; crea il profilo se manca. Stato e note non modificabili |
| `GET /admin/installers` | admin | `?status&q (ragione sociale/email)&limit&offset` → `InstallerAdminDto` |
| `GET /admin/installers/:id` · `PATCH` `{ notes }` | admin | `:id` = `user_id` |
| `POST /admin/installers/:id/approve` · `/reject` | admin | `{ reason?, notes? }`. Dettagli sotto |
| `POST /admin/installers/:id/sync` | admin | riallinea il gruppo Medusa allo stato attuale (Medusa giù → 503) |
| `GET /admin/installers/nearby?lat=&lng=&limit=` | admin | `NearbyInstallerDto[]` con `distanceKm`, ordinati per distanza |
| `GET /admin/quote-requests/:id/installers` | admin | provincia della richiesta → capoluogo → installatori vicini; 409 `PROVINCE_MISSING` |
- **Approvazione e rifiuto** (`/approve`, `/reject`):
1. in transazione: `status` e `approved_at` (azzerato al rifiuto); all'approvazione il ruolo diventa `installer` (un admin non può esserlo);
2. `syncInstallerGroup`: aggiunge o toglie il customer Medusa dal gruppo `installatori`, cioè dal listino riservato;
3. email `installer-approved` / `installer-rejected` (con `reason`).
- Se Medusa non risponde, la decisione resta valida e la risposta ha **`medusaSynced: false`**.
- **Recupero della sincronizzazione**: a **ogni login di un installatore** parte in background `syncInstallerGroup` (idempotente), oltre a `POST /admin/installers/:id/sync`.
- **Effetto immediato** sull'accesso: documenti `installer` (`visibleLevels`) e prezzi riservati (`ShopService.viewerFor`) leggono `installer_profiles.status` dal DB a ogni richiesta, quindi l'approvazione vale subito, anche con lo stesso access token. Lo stesso vale per il rifiuto. Il ruolo nel token conta solo per `requireRole` (es. `/installer/profile`).
- **Coordinate**: niente geocoding lato server. Il frontend usa un selettore su mappa e invia `{ lat, lng }`, validato su range e su un riquadro dell'Italia (`ITALY_BOUNDS` / `isInItaly`, spostati in `@iti/shared` → `validation.ts` nel prompt 31 e riesportati da `geo.ts`: il selettore su mappa dell'area riservata usa gli stessi confini), altrimenti 400. In DB è `geography(Point,4326)`.
- **Ricerca** (`findNearbyInstallers`):
- condizioni: profilo `approved`, ruolo `installer`, account non disabilitato, sede impostata e `ST_DWithin(location, punto, service_radius_km * 1000)`;
- ordinamento per `ST_Distance` (metri su geography → `distanceKm` con 1 decimale);
- il raggio varia per riga, quindi l'indice GIST non filtra a monte. Va bene per centinaia di installatori; per volumi molto maggiori aggiungere un prefiltro `ST_DWithin` con il raggio massimo.
- **`PROVINCE_CENTROIDS`** (`geo.ts`): coordinate del capoluogo per ognuna delle 107 sigle di `PROVINCES` (un test verifica che ci siano tutte). Si usano per le richieste di cui si conosce solo la provincia.
- **Test** (`test/installers.test.ts`, 8 casi):
- 7 installatori attorno a Monza: Milano 50 km di raggio (~15 km, incluso), Bergamo 40 (~34, incluso), Lecco 10 (~31, escluso per raggio), Torino 20 (~135, escluso), più pending, disabilitato e senza sede (esclusi). Risultato `[Milano, Bergamo]` con le distanze;
- richiesta → provincia → installatori; coordinate fuori Italia; profilo (validazione, note nascoste, stato non modificabile);
- **pending → nessun documento installer né prezzo riservato; dopo l'approvazione sì, con lo stesso token; dopo il rifiuto di nuovo no**;
- Medusa giù all'approvazione → riallineato al login; un customer approvato diventa installer.
**Utenti e dashboard admin** (prompt 32, `src/routes/admin/users.ts`, `src/routes/admin/summary.ts`):
| Route | Note |
|---|---|
| `GET /admin/users` | `?role&status=active\|disabled&q (email, nome, cognome, "nome cognome", ragione sociale)&limit&offset` → `Paginated<UserAdminDto>` (`UserDto` + `disabledAt`, `hasShopAccount`, `installerStatus`, `updatedAt`; mai hash né id Medusa) |
| `GET /admin/users/:id` | 404 `USER_NOT_FOUND` |
| `POST /admin/users/:id/disable` · `/enable` | disabilita: `disabled_at` e **tutte le sessioni revocate** (le route che rileggono l'utente lo rifiutano subito); per un installatore riallinea il gruppo Medusa (fuori dal listino riservato; riabilitandolo rientra). Risposta con `medusaSynced` (`null` se non serve, `false` se Medusa non risponde). Il proprio account → 409 `SELF_DISABLE` |
| `GET /admin/summary` | `AdminSummaryDto`: richieste `new`, ticket `open` e non chiusi, installatori `pending`, visite attive future, contatti `new`, preventivi `sent` non scaduti |
- **Test** (`test/admin.test.ts`, 5 casi): ricerca per testo/ruolo/stato e campi esposti, 403/401; disabilita → login e route rifiutati, riabilita; installatore fuori e dentro il gruppo Medusa (anche con Medusa giù → `medusaSynced: false`); auto-disabilitazione 409; contatori.
**Contatti, showroom e consensi GDPR** (route `src/routes/public/{contact,showroom}.ts`, `src/routes/private/consents.ts`, `src/routes/admin/{contacts,showroom}.ts`, servizi `form-guard.ts`, `consents.ts`, `showroom.ts`, configurazione `src/showroom/`):
| Route | Accesso | Note |
|---|---|---|
| `GET /forms/token` | pubblico | `FormTokenDto { token, minSeconds, expiresAt }`, `no-store` (60/min). Da chiedere all'apertura di un form pubblico |
| `POST /contact` | auth facoltativa | `{ name, email, phone?, subject?, message (10-5000), acceptPrivacy: true, acceptMarketing?, formToken, website? }` → **200 `{ message }`** (5 ogni 15 min per IP) |
| `GET /showroom/slots` | pubblico | `?from=YYYY-MM-DD&days (1-31, 14)` → `ShowroomSlotsDto`: solo giorni aperti, slot con `start`/`end` ISO e `available` (posti liberi, 0 = completo) |
| `POST /showroom/bookings` | auth facoltativa | `{ name, email, phone?, requestedAt (start di uno slot), people (1-4), notes?, acceptPrivacy: true, acceptMarketing?, formToken, website? }` → **201 `ShowroomBookingDto`** `confirmed` / 400 `INVALID_SLOT` / **409 `SLOT_FULL`** (5 ogni 15 min) |
| `GET /me/consents` | autenticato | `{ marketing: boolean, items: ConsentDto[] }` (più recenti prima) |
| `POST /me/consents/marketing` · `DELETE` | autenticato | dà (origine `account`) o revoca il consenso marketing; idempotenti, rispondono come `GET` |
| `POST /me/delete-account` | autenticato | `{ password, reason? }` → 202. Dettagli sotto |
| `GET /admin/contact-requests` (`?status&q`) · `GET/PATCH /admin/contact-requests/:id` `{ status }` | admin | `ContactRequestDto` |
| `GET /admin/showroom/bookings` (`?status&from&to&q`, per data visita) · `GET/PATCH /admin/showroom/bookings/:id` `{ status }` | admin | `ShowroomBookingAdminDto` |
- **Anti-spam senza servizi terzi** (`services/form-guard.ts`, campi `formGuardFields`), usato da contatti e showroom:
- **honeypot** `website`: il frontend lo rende invisibile (non `type="hidden"`, ma fuori schermo con `tabindex="-1"` e `autocomplete="off"`); se è compilato la richiesta viene **scartata in silenzio**, con la stessa risposta di successo;
- **tempo minimo di compilazione**: `formToken` = `<istante emissione>.<HMAC-SHA256>` (chiave `COOKIE_SECRET`, prefisso di dominio `form-token:`), quindi il client non può falsificare l'istante. Inviato prima di `FORM_MIN_SECONDS` (3 s) → scartato in silenzio; firma non valida, formato errato o più vecchio di 24 h → **400 `FORM_EXPIRED`** (il frontend chiede un nuovo token e reinvia);
- **rate limit per IP** sulle route (5 ogni 15 minuti).
- Gli scarti finiscono nei log (`info`, "invio scartato dall'anti-spam", con il motivo).
- **Consensi** (`services/consents.ts`): ogni form con dati personali ha `acceptPrivacy: z.literal(true)` (mancante o `false` → 400 `VALIDATION_ERROR`, niente salvato) e `acceptMarketing` facoltativo, separato. `recordConsents` salva, nella stessa transazione del form, `privacy` ed eventualmente `marketing` con `PRIVACY_POLICY_VERSION`, origine (`register`, `contact`, `showroom`, `configurator`, `account`), IP, timestamp, `user_id` se autenticato ed email. Le righe non si cancellano: la revoca valorizza `revoked_at`.
- I consensi dell'utente sono quelli col suo `user_id` e, **solo con email verificata**, quelli dati da anonimo con la stessa email (stesso principio di richieste e ordini guest). La revoca marketing li revoca tutti.
- **Contatti**: salva `contact_requests` (email normalizzata, `user_id` se autenticato) e avvisa lo staff (`notifyStaff`, link `PRIVATE_APP_URL/admin/contatti/<id>`). Nessuna email di conferma al mittente: il form non deve poter essere usato per mandare email a indirizzi arbitrari.
- **Showroom** (`src/showroom/config.ts` → `SHOWROOM`): fuso `Europe/Rome`, visite da **60 min**, **capienza 6 persone** per slot (somma delle prenotazioni `requested`/`confirmed`), max **4 persone** per prenotazione, preavviso **24 h**, orizzonte **60 giorni**. Orari: lun-ven 9-12 e 14-18, sabato 9-12, domenica chiuso; chiuso nelle festività nazionali fisse, a Pasquetta (calcolata) e nelle `closedDates` (ferie, chiusure straordinarie). **Valori provvisori**: cambiarli richiede solo di modificare `config.ts` (più i test che li usano).
- `slots.ts` (funzioni pure): gli orari locali diventano istanti UTC corretti anche nei giorni del cambio d'ora. `requestedAt` deve coincidere **esattamente** con l'inizio di uno slot prenotabile, altrimenti 400 `INVALID_SLOT`.
- **Capienza in concorrenza**: `reserveSeats` prende `pg_advisory_xact_lock(19002, minuti dall'epoca dello slot)` nella transazione di inserimento, poi somma i posti occupati. Le prenotazioni sullo stesso slot si serializzano fino al commit: oltre la capienza → 409 `SLOT_FULL` (testato con 5 richieste in parallelo).
- La prenotazione nasce **`confirmed`** (la disponibilità è verificata subito): email `showroom-booking-confirmed` al cliente e `notifyStaff` (link `PRIVATE_APP_URL/admin/showroom/<id>`). Lo stato `requested` resta disponibile per l'admin.
- Admin, cambio stato: `cancelled`/`completed` liberano i posti; riportare una prenotazione in uno stato attivo **ricontrolla la capienza** (409 se lo slot si è riempito); il passaggio a `confirmed` da un altro stato rimanda l'email di conferma.
- **Cancellazione account** (`POST /me/delete-account`): richiede la **password** (401 `INVALID_CREDENTIALS` se errata; non basta un access token rubato). Poi: `disabled_at` valorizzato (login, refresh e route autenticate rifiutati), consenso marketing revocato, **tutte le sessioni** revocate, `notifyStaff` con utente, ruolo, id customer Medusa e motivo (link `PRIVATE_APP_URL/admin/utenti/<id>`). Un admin non può cancellarsi da solo (409 `ADMIN_ACCOUNT`). La **cancellazione effettiva dei dati resta manuale** nell'MVP (DB applicativo, customer e ordini Medusa, consensi da conservare come prova).
- **Test** (`test/contact.test.ts` 10, `test/showroom.test.ts` 10):
- token: firma, tempo minimo, scadenza, data alterata; contatti salvati con consensi e notifica; senza privacy → 400; honeypot e invio rapido → 200 senza nulla salvato né inviato; token scaduto → 400; rate limit → 429; admin;
- consensi dell'utente (anonimi collegati solo con email verificata), revoca e nuovo consenso idempotenti; cancellazione account (password errata, sessioni, login rifiutato, notifica), admin → 409;
- slot: orari feriali e del sabato, ora legale/solare e cambio d'ora, Pasquetta, festività, chiusure, preavviso e orizzonte; prenotazione con email e consensi; **capienza superata → 409**; 5 prenotazioni concorrenti → una sola accettata; slot non valido, persone oltre il massimo, privacy mancante; anti-spam; admin (annullamento, riattivazione con capienza, email di conferma).
- **Verificato** con il server di sviluppo: `/showroom/slots` (sabato → primo giorno lunedì, 9:00 = 07:00Z), `/forms/token`, invio immediato a `/contact` scartato come troppo rapido.
**Email transazionali** (`src/services/email.ts`, template in `src/emails/`):
- **`app.email.send({ to, template, data })`**: `data` è tipizzato per template (`EmailTemplates` in `src/emails/index.ts`).
- Il **render avviene subito**: un errore di template è un bug, quindi `send` lancia.
- La **consegna avviene in background**: un errore del provider viene loggato (`error` "invio email non riuscito", con destinatario, template e provider) e **non fa fallire la richiesta HTTP**. Conseguenza: un'email fallita non fa ritentare il webhook Medusa.
- Fino a **3 tentativi** con backoff (1 s, 2 s) per errori di rete, 429 e 5xx; i 4xx non vengono ritentati.
- `app.email.flush()` attende le consegne in corso; viene chiamata anche alla chiusura del server.
- Sostituibile in `buildApp(config, { email })`. Nei test si usa `MemoryEmailService`, che renderizza con gli stessi template e conserva i messaggi (`sent`, `last(tag)`).
- **Provider** da `EMAIL_PROVIDER`: **cambiare provider richiede solo variabili d'ambiente**, validate all'avvio (mittente e chiave mancanti → elenco errori ed exit 1).
- `console` (default): nei log solo destinatario, template e oggetto, mai il corpo. Con `EMAIL_OUTBOX_DIR` salva `.txt` e `.html` completi. In produzione all'avvio scrive un warning, perché le email non vengono inviate.
- `brevo`: `POST https://api.brevo.com/v3/smtp/email`, header `api-key` = `BREVO_API_KEY`; tag = nome del template.
- `resend`: `POST https://api.resend.com/emails`, `Authorization: Bearer RESEND_API_KEY`; tag `template=<nome>`.
- Mittente `EMAIL_FROM_NAME <EMAIL_FROM>`, da verificare sul provider (SPF/DKIM del dominio). `EMAIL_REPLY_TO` è facoltativa.
- **Template** (nome = tag), ciascuno con oggetto, HTML e testo:
| Template | Uso | Dati |
|---|---|---|
| `verify-email` | registrazione / reinvio | `firstName, link` |
| `reset-password` | password dimenticata | `firstName, link` |
| `register-existing` | registrazione con email già usata (anti-enumerazione) | `firstName, forgotPasswordUrl` |
| `order-confirmation` | webhook `order.placed` | `order: OrderEmailData, ordersUrl` (il link si omette per i guest) |
| `order-placed-admin` | webhook `order.placed`, notifica interna | `order` |
| `order-canceled` | webhook `order.canceled` | `order` |
| `order-shipped` | webhook `order.fulfillment_created` | `order, tracking[{ number, url }]` |
| `quote-request-admin` | nuova richiesta di preventivo, interna (configuratore) | `name, email, phone, province, summary[{ label, value }], adminUrl` |
| `quote-request-received` | conferma all'utente della richiesta dal configuratore | `name, solutionTitle, summary, disclaimer` |
| `quote-sent` | preventivo inviato (prompt 19) | `firstName, quoteNumber, title, amountCents, validUntil, link` |
| `ticket-reply` | risposta a un ticket (prompt 19) | `firstName, ticketSubject, message, link` |
| `showroom-booking-confirmed` | prenotazione showroom (alla creazione e alla conferma da admin) | `name, requestedAt (ISO), people, notes` |
| `installer-approved` | profilo installatore approvato | `firstName, businessName, link` |
| `installer-rejected` | profilo installatore rifiutato | `firstName, businessName, reason` |
| `staff-notification` | notifica interna generica (nuovo ticket, risposta del cliente, preventivo accettato/rifiutato, contatto, visita showroom, cancellazione account); helper `notifyStaff` | `title, intro, rows[{ label, value }], url` |
`OrderEmailData` è indipendente da Medusa: importi in centesimi e righe dell'indirizzo già composte. Si costruisce con `toOrderEmailData(order)` in `medusa-webhooks.ts`.
- **Layout e sicurezza** (`src/emails/layout.ts`, `html.ts`):
- layout comune a tabelle con stili inline: intestazione brand (`#0b5c7a`), preheader, piè di pagina;
- il tag `html\`…\`` **escapa ogni valore interpolato** (tranne i frammenti `SafeHtml` prodotti da `html` stesso);
- i link passano da `safeUrl` (solo http/https, altrimenti `#`) e gli oggetti da `oneLine` (niente a capo);
- formattazione: `formatCents` → `1.190,00 €` (`useGrouping: "always"`, perché il CLDR it-IT non separa le migliaia sotto le 5 cifre); date in italiano, fuso `Europe/Rome`.
- **Aggiungere un template**: funzione di render in `src/emails/*.ts`, poi voce in `EmailTemplates` e in `renderers` (`index.ts`), esempio nei test (`test/emails.test.ts`, che fallisce se ne manca uno) e in `preview.ts`.
- **Anteprima**: `npm run email:preview -w apps/api [-- destinatario]` invia tutti i template con dati realistici tramite il provider configurato. Con `console` + `EMAIL_OUTBOX_DIR` si aprono gli `.html` nel browser; con brevo/resend parte un invio reale.
- **Test** (`test/emails.test.ts`, 22 casi):
- rendering di ogni template con dati ostili (`<script>`, virgolette, `&`), che risultano escapati nell'HTML;
- link `javascript:` neutralizzati, oggetti senza a capo;
- importi, date con fuso;
- richieste Brevo/Resend (endpoint, header, corpo), errori ritentabili o no;
- outbox console; retry e nessuna eccezione sugli errori del provider;
- validazione della configurazione.
- **Verificato**: `npm run email:preview` con `console` → 11 template loggati e salvati in `.outbox` (`.txt` + `.html`). Invio reale con Brevo/Resend **non verificato** (provider non ancora scelto, nessuna chiave).
**Webhook Medusa** (`src/routes/webhooks/medusa.ts`, `src/services/medusa-webhooks.ts`, template in `src/emails/orders.ts`):
- `POST /webhooks/medusa`: è l'endpoint a cui Medusa invia gli eventi ordine (formato e firma nella sezione Medusa).
- **Firma prima del parse**: nel plugin incapsulato il parser `application/json` riceve il body grezzo (`parseAs: "buffer"`) e verifica `x-signature`, HMAC-SHA256 esadecimale con `WEBHOOK_SECRET`, confrontando i digest con `timingSafeEqual`. Firma assente, malformata o errata → **401 `INVALID_SIGNATURE`**, e il body non viene nemmeno interpretato. Poi il payload passa la validazione zod (`id` uuid, `event`, `occurred_at`, `data`), altrimenti 400.
- **Escluso** dal rate limit globale e dal CORS: `config: { rateLimit: false, cors: false }`.
- **Idempotenza** (`handleMedusaWebhook`):
1. insert in `webhook_events` con l'id dell'evento (`on conflict do nothing`);
2. in una transazione, lock della riga con `SELECT … FOR UPDATE`: le consegne concorrenti dello stesso evento si mettono in coda;
3. se `processed_at` è già valorizzato → **200 `duplicate`**; altrimenti dispatch e `processed_at`.
- Risposte: `200 { status: "processed" | "ignored" | "duplicate" }`. Gli eventi non gestiti vengono registrati come elaborati (`ignored`).
- Se l'elaborazione fallisce: errore (con timestamp) salvato in `webhook_events.error` e risposta **500 `WEBHOOK_PROCESSING_FAILED`**. L'evento resta non elaborato, quindi il retry di Medusa lo rielabora; al successo `error` torna `null`.
- Limite: se un evento invia più email e fallisce a metà, il retry rimanda anche quelle già partite.
- **Dispatcher**: rilegge l'ordine da Medusa con `GET /internal/orders/:id`, perché lo snapshot del payload non contiene righe né indirizzo; se Medusa non risponde → 500 → retry.
- `order.placed` → `order-confirmation` al cliente (righe, totali con IVA, indirizzo, link `PRIVATE_APP_URL/ordini`) + `order-placed-admin` a `ADMIN_NOTIFY_EMAILS` (csv), oppure, se vuota, a tutti gli utenti `admin` non disabilitati.
- `order.canceled` → `order-canceled` (nota sul rimborso).
- `order.fulfillment_created` → `order-shipped`, con il tracking delle `labels` di quel fulfillment se presenti; nessuna email se `no_notification`.
- Le email partono in background: un errore del **provider email** non fa fallire il webhook (viene solo loggato). Un errore nella lettura dell'ordine da Medusa invece sì (500 → retry).
- ⚠️ **Tracking**: in Medusa 2.21 `POST /admin/orders/:id/fulfillments` non accetta `labels`; il tracking si aggiunge con la spedizione (`/fulfillments/:id/shipments`, evento `order.shipment_created`), che il subscriber del prompt 07 **non inoltra**. Quindi oggi l'email di spedizione parte alla creazione del fulfillment, senza tracking. Se serve il tracking: inoltrare `shipment_created` e spostare lì l'email.
- **Test** (`test/webhooks.test.ts`, 13 casi): firma valida, errata, assente, con altra chiave, body alterato; JSON e payload non validi; stesso evento due volte e tre consegne concorrenti → una sola elaborazione; errore → 500 con errore salvato, poi retry riuscito; contenuto delle email; `ADMIN_NOTIFY_EMAILS`; `no_notification`; evento ignorato; niente rate limit né CORS.
- **Verificato con Medusa reale**: due ordini (draft convertiti), un fulfillment, un annullamento → 4 eventi in `webhook_events` elaborati senza errori; email `order-confirmation`, `order-placed-admin`, `order-canceled` e `order-shipped` generate (adapter console, file in `.outbox`).
**Database (Drizzle ORM su `iti_app`)**:
- `drizzle-orm` 0.45 + driver `postgres` (postgres.js), `drizzle-kit` 0.31 (versioni stabili, non le 1.0 rc). Schema in `src/db/schema/*.ts` (riesportato da `index.ts`), client in `src/db/client.ts` (`createDb(url)` → `{ db, close }`), plugin `src/plugins/db.ts` → **`app.db`** (connessione lazy alla prima query, pool chiuso con il server).
- Env `DATABASE_URL` = `postgres://iti_app:<APP_DB_PASSWORD>@localhost:5432/iti_app`. Gli script db usano solo gli schemi parziali `dbEnvSchema` / `seedEnvSchema` di `src/config/env.ts` (`parseEnv(schema)`), quindi non richiedono le altre variabili del server.
- Comandi (da root):
```powershell
npm run db:generate -w apps/api -- --name <descrizione> # drizzle-kit: nuova migrazione SQL da modifiche allo schema
npm run db:migrate -w apps/api # applica le migrazioni (migrator Drizzle, tabella drizzle.__drizzle_migrations)
npm run db:seed -w apps/api # admin da SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD (≥ 12), idempotente
npm run db:studio -w apps/api # drizzle-kit studio
```
- **Migrazioni** versionate in `src/db/migrations` (SQL + `meta/`, da committare; non modificare quelle già applicate, generarne di nuove). `db:migrate` usa il migrator programmatico (non `drizzle-kit migrate`), che risolve la cartella dalla root del package: funziona anche dalla build (`node dist/db/migrate.js`), purché `src/db/migrations` sia presente nell'immagine. `drizzle.config.ts` ha `extensionsFilters: ["postgis"]` (ignora `spatial_ref_sys`). Le estensioni (`postgis`, `vector`, `pg_trgm`, `unaccent`) le crea l'init del container, non le migrazioni (l'utente `iti_app` non è superuser).
- **Tabelle di identità** (migrazione `0000_init_users`):
- `users`: `id` uuid (`gen_random_uuid()`), `email` (**sempre minuscola**: check `users_email_lowercase` + indice unique `users_email_lower_key` su `lower(email)`), `password_hash` (argon2id), `role` enum `user_role` (`customer|company|installer|admin`, default `customer`; i valori vengono da `UserRole` di `@iti/shared`), `first_name`, `last_name`, `company_name`, `vat_number`, `medusa_customer_id` unique, `email_verified_at`, `created_at`, `updated_at` (aggiornato da Drizzle con `$onUpdate`), `disabled_at`.
- `refresh_tokens`: `id`, `user_id` FK cascade, `token_hash` unique, `family_id` uuid (indice), `expires_at`, `revoked_at`, `created_at`, `user_agent`, `ip` (`inet`).
- `password_reset_tokens`, `email_verification_tokens`: `id`, `user_id` FK cascade, `token_hash` unique, `expires_at`, `used_at`, `created_at`.
- Tipi esportati dallo schema: `User`, `NewUser`, `RefreshToken`, `NewRefreshToken`, `PasswordResetToken`, `EmailVerificationToken`.
- **Segreti mai in chiaro**:
- token opachi: `generateToken()` in `src/services/tokens.ts` → `{ token, hash }`, 256 bit casuali in base64url; in DB **solo** `hash` (SHA-256 hex, ricerca diretta per `token_hash`). Il token va solo al client.
- password: `hashPassword` / `verifyPassword` (`src/services/password.ts`, argon2id via `@node-rs/argon2`, binari precompilati, nessuna compilazione su Windows).
- Seed: crea l'admin con `email_verified_at` valorizzato; se l'email esiste non tocca nulla (avvisa se il ruolo non è admin). Admin di dev: `admin@iti.local`, password in `apps/api/.env` (`SEED_ADMIN_PASSWORD`).
- `npm audit`: 4 avvisi "moderate" su `esbuild` interno a `drizzle-kit` (solo dev server di esbuild, mai avviato; strumento solo di sviluppo). Il fix proposto da npm (`drizzle-kit@0.18`) è un downgrade, quindi non applicato. Le dipendenze runtime hanno 0 vulnerabilità.
- **Verificato**: `db:migrate` su DB vuoto di prova → 4 tabelle, rieseguito senza errori; applicato a `iti_app`. `db:seed` → creato, poi "già presente". Insert di email maiuscola → violazione del check; email duplicata → violazione dell'indice unique. `db:generate` dopo la migrazione → "No schema changes". Test vitest su token, password e assenza di colonne in chiaro.
- **Verificato**: 12 test vitest (config mancante o non valida, `LOG_DIR` in produzione, `/health`, 404, validazione, AppError, 500 senza stack e generico in produzione, CORS, header helmet, 429). `npm run dev -w apps/api` → `/health` 200. Avvio senza `.env` → elenco delle variabili mancanti ed exit 1. Build avviata con `NODE_ENV=production` → log su file `api.2026-10-02.1.log` e su stdout.
## Deploy (produzione)
Tutto gira con **`docker-compose.prod.yml`** (progetto `iti-technology`) sulla VPS. Le variabili stanno nel **`.env` della root** del repo sulla VPS, copiato da **`.env.prod.example`** (in sviluppo il `.env` di root è quello di `docker-compose.dev.yml`: due file diversi su due macchine diverse). Segreti solo esadecimali (`openssl rand -hex 32`), perché finiscono anche dentro gli URL `postgres://`.
**Servizi e immagini**:
| Servizio | Immagine (Dockerfile) | Note |
|---|---|---|
| `postgres` | `infra/postgres` | stesso init dello sviluppo (crea anche `iti_app_test`, inutilizzato); volume `postgres-data` |
| `redis` | `redis:7-alpine` | AOF, volume `redis-data` |
| `medusa` | `medusa/Dockerfile` (contesto `medusa/`) | `medusa build`, poi runtime con i `node_modules` del monorepo Medusa (ripuliti dalle devDependencies, proprietario `node`: `db:migrate` crea cartelle `migrations/` dentro i moduli). Avvio = `medusa db:migrate && medusa start` da `.medusa/server`. `DATABASE_URL` con **`?sslmode=disable`** (con `NODE_ENV=production` Medusa usa SSL verso Postgres e va in timeout). Telemetria disattivata. File in `/data/medusa-files` (volume) |
| `api` | `apps/api/Dockerfile` (contesto root) | `npm ci` dei soli workspace api + shared, `tsc`; runtime con le sole dipendenze di produzione. Avvio = `node dist/db/migrate.js && node dist/server.js`. Volumi `uploads` (`/data/uploads`) e `api-logs` (`/var/log/iti`, `LOG_DIR`) |
| `public` | `apps/public/Dockerfile` | il server SSR è autosufficiente (nessun `node_modules` a runtime); healthcheck su `/robots.txt` |
| `caddy` | `infra/caddy/Dockerfile` | `caddy:2-alpine` (2.11) + build di `apps/private` in `/srv/area`; unico servizio con porte (80, 443 tcp/udp) |
| `backup`, `restore` | immagine Postgres | profilo `tools`: non partono con `up` |
- **Reti**: `backend` (`internal: true`, nessuna uscita verso Internet) con Postgres, Redis, Medusa, API e strumenti; `edge` con Caddy, API, sito e Medusa (che escono verso Stripe e il provider email). Nessuna porta pubblicata tranne Caddy.
- **Log**: driver `json-file` con rotazione (10 MB × 5) per tutti; l'API scrive anche su file nel volume `api-logs`. L'healthcheck `/health` dell'API ha `logLevel: "warn"`, così non riempie i log.
- `.dockerignore` in root e in `medusa/` (niente `node_modules`, `dist`, `.env`, uploads). `.gitattributes` forza LF su `*.sh`, `Caddyfile`, `Dockerfile`, `*.sql` (girano in container Linux).
**Caddy** (`infra/caddy/Caddyfile`, variabili `DOMAIN`, `ACME_EMAIL`, `SHOP_ADMIN_USER`, `SHOP_ADMIN_PASSWORD_HASH`, `SHOP_ADMIN_ALLOWED_IPS`):
| Host | Destinazione | Note |
|---|---|---|
| `{$DOMAIN}` | `public:4000` | CSP di base (sotto), `X-Frame-Options: DENY`, Permissions-Policy |
| `www.{$DOMAIN}` | 301 → `https://{$DOMAIN}{uri}` | |
| `area.{$DOMAIN}` | `/srv/area` | `try_files {path} /index.html` (fallback SPA); `/config.json` generato da Caddy; asset con hash `immutable` 1 anno, il resto (index.html compreso) `no-cache`; `X-Robots-Tag: noindex` |
| `api.{$DOMAIN}` | `api:3000` | `X-Request-Id` generato da Caddy; **`/hooks/payment/stripe_stripe` → `medusa:9000`** (unico percorso di Medusa esposto: niente Store API) |
| `shop-admin.{$DOMAIN}` | `medusa:9000` | 1) whitelist IP (403); 2) solo `/app`, `/admin/*`, `/auth/user/*`, `/auth/session`, `/auth/token/refresh` (il resto 404); 3) basic auth; `/` → `/app/` |
| `media.{$DOMAIN}` | `/srv/uploads` (= `uploads/public`); `/shop/*` → `/srv/medusa-files` | CSP `default-src 'none'; sandbox`, dotfile 404, file Medusa `private-*` (export CSV) 404 |
- Comuni: HSTS 1 anno (senza `includeSubDomains`, per non vincolare altri sottodomini del dominio), `nosniff`, `Referrer-Policy: strict-origin-when-cross-origin`, compressione zstd/gzip, log JSON su stdout. Certificati Let's Encrypt automatici, salvati nel volume `caddy-data`: **non cancellarlo**, i rate limit di Let's Encrypt sono bassi.
- Dentro un sito Caddy riordina le direttive per tipo (es. `handle` prima di `respond`): dove l'ordine conta (dashboard Medusa) si usa `route`.
- **uploads/private non raggiungibile**: Caddy monta il volume `uploads` con **`subpath: public`** in sola lettura, quindi `private/` non esiste nemmeno nel container. Conseguenza: la cartella `uploads/public` **non va mai cancellata e ricreata**, altrimenti il mount resta sulla vecchia e i file escono 404 fino al riavvio di Caddy. `restore.sh` svuota il contenuto ma conserva le cartelle di primo livello.
- **Basic auth e dashboard Medusa**: il dashboard (`auth.type: "session"`) dopo il login chiama `POST /auth/session` con `Authorization: Bearer <token>`, e il browser non può inviare insieme le credenziali basic (stesso header). Per questo le richieste `/auth/*` **con** `Authorization: Bearer` sono esenti dalla basic auth: il token si ottiene solo da `/auth/user/emailpass`, che la richiede. Verificato: senza basic auth login 401; con basic auth token → `/auth/session` 200 → `/admin/users/me` 200; un cookie di sessione o un Bearer valido **senza** basic auth → 401 sulle altre route.
- **Whitelist**: `SHOP_ADMIN_ALLOWED_IPS` = CIDR separati da spazio (vuota = tutti). Con la pubblicazione delle porte di Docker su Linux l'IP sorgente resta quello reale; al primo deploy controllare nei log di Caddy (`remote_ip`) che sia così.
- **CSP** (prompt 34, nessun `'unsafe-inline'` negli script):
- sito: la manda il server SSR con un nonce per risposta (`apps/public/src/server/csp.ts`). Caddy imposta solo un default restrittivo con `?Content-Security-Policy`, che vale se la risposta non ne ha una (statici, errori). Verificato con Caddy reale;
- area riservata: `script-src 'self'` (build senza CSS critici inline), più API, media e tile OSM;
- `style-src 'unsafe-inline'` resta in entrambe (stili dei componenti, attributi style di Leaflet).
- **Siti aggiuntivi**: `import /etc/caddy/sites/*.caddy` (cartella `infra/caddy/sites/`, montata in sola lettura). Esempio `n8n.caddy.example`: se n8n (già sulla VPS) occupa oggi le porte 80/443, va messo dietro questo Caddy.
**Record DNS** (zona del dominio):
| Nome | Tipo | Valore |
|---|---|---|
| `@` (il dominio) | A (+ AAAA se la VPS ha IPv6) | IP della VPS |
| `www`, `area`, `api`, `shop-admin`, `media` | A/AAAA, oppure CNAME → dominio | IP della VPS |
| `@` | CAA (facoltativo) | `0 issue "letsencrypt.org"` |
In alternativa ai cinque record basta un wildcard `*` A/AAAA. I record devono risolvere **prima** dell'avvio di Caddy (challenge HTTP-01 sulla porta 80, che va aperta nel firewall insieme a 443 tcp/udp). Per le email transazionali servono a parte i record SPF/DKIM del provider scelto.
**Primo deploy** (VPS con Docker + Compose v2 e Node/npm per gli script; altrimenti si usano i comandi `docker compose` equivalenti):
1. DNS come sopra; clonare il repo; `cp .env.prod.example .env` e compilare. Hash della basic auth: `docker run --rm caddy:2-alpine caddy hash-password --plaintext '…'`, da scrivere tra **apici singoli** nel `.env` (contiene `$`).
2. `npm run deploy:build`.
3. `docker compose -f docker-compose.prod.yml up -d --wait postgres redis medusa`.
4. `npm run deploy:seed:medusa` (con `STRIPE_API_KEY` impostata: `seed-base` si ferma senza provider Stripe) → copiare nel `.env` la riga `MEDUSA_PUBLISHABLE_KEY=pk_…` (è dentro un log JSON). Senza chiave l'API non parte (configurazione non valida).
5. `npm run deploy:up` → tutti i servizi healthy, certificati emessi.
6. `npm run db:seed:prod` (admin dell'area riservata da `SEED_ADMIN_*`, che dopo si possono svuotare) e `docker compose -f docker-compose.prod.yml exec medusa medusa user -e <email> -p <password>` (utente del dashboard).
7. Stripe: webhook su `https://api.<dominio>/hooks/payment/stripe_stripe` (eventi nella sezione Medusa), `whsec_…` in `STRIPE_WEBHOOK_SECRET`, poi `npm run deploy:up`. `SITE_INDEXABLE=true` solo sul dominio definitivo.
**Script npm** (root; usano il `.env` della cartella corrente):
| Script | Comando |
|---|---|
| `deploy:build` | `docker compose -f docker-compose.prod.yml build` |
| `deploy:up` | `… up -d --wait --remove-orphans` (aggiornamento: `git pull`, `deploy:build`, `deploy:up`) |
| `deploy:down`, `deploy:logs` | stop / log in follow |
| `deploy:seed:medusa` | `seed-base.js` + `seed-installatori.js` nel container Medusa (idempotenti) |
| `db:migrate:prod` | migrazioni API (`node dist/db/migrate.js`) e Medusa (`medusa db:migrate`) in container temporanei. Partono comunque a ogni avvio dei due servizi |
| `db:seed:prod` | admin iniziale dell'area riservata |
| `backup:prod` | `… run --rm backup` |
| `backup:restore` | `… run --rm restore` (+ `-- <cartella>` oppure `-- latest`) |
**Deploy automatico** (`.gitea/workflows/deploy.yml`, Gitea Actions): a ogni push su `master` si collega via SSH al server (`appleboy/ssh-action`), fa `git fetch` + `git reset --hard origin/master` in `DEPLOY_PATH`, poi `build` e `up -d --wait --remove-orphans` di `docker-compose.prod.yml`, infine `docker image prune -f`. Secret del repository: `DEPLOY_HOST`, `DEPLOY_PORT`, `DEPLOY_USER`, `DEPLOY_SSH_KEY`, `DEPLOY_PATH`. Il server deve avere già il clone (con accesso in lettura al repo) e il `.env` di produzione, che il reset non tocca perché è ignorato da git; senza `.env` il job fallisce. Il **primo deploy** (passi sopra) resta manuale.
**Backup** (`infra/backup/backup.sh`, container `backup`):
- In `BACKUP_DIR` (host, default `./backups`, in `.gitignore`) crea `<AAAAMMGGTHHMMSSZ>/` con `app.dump` e `medusa.dump` (`pg_dump -Fc` da superuser, proprietari inclusi), `uploads.tar.gz` (public e private), `medusa-files.tar.gz`, `SHA256SUMS`. Scrive in `.tmp-…` e rinomina solo a backup completo; file con `umask 077`. Tiene gli ultimi `BACKUP_KEEP_DAYS` (default 14). Redis non serve (cache, sessioni, code).
- **Pianificazione giornaliera** con il cron dell'host, per esempio:
```
30 3 * * * cd /opt/iti-technology && docker compose -f docker-compose.prod.yml run --rm backup >> /var/log/iti-backup.log 2>&1
```
- **Integrazione con il backup della VPS**: includere `BACKUP_DIR` nel backup già esistente (copia fuori macchina). I dump contengono dati personali e hash delle password: la destinazione deve essere cifrata o comunque riservata.
**Ripristino** (`infra/backup/restore.sh`, container `restore`; sostituisce tutto):
```powershell
docker compose -f docker-compose.prod.yml stop api medusa public
npm run backup:restore -- latest # oppure -- 20261005T134509Z
npm run deploy:up
```
- Verifica i checksum; **rifiuta di partire** se ci sono connessioni di `iti_app`/`iti_medusa` (servizi non fermati); per ogni database `DROP DATABASE … WITH (FORCE)`, `CREATE DATABASE` col proprietario, `pg_restore --exit-on-error` da superuser (ricrea anche le estensioni); poi svuota e riestrae i due volumi di file (proprietari conservati).
- Su una macchina nuova: primo deploy fino al punto 5 (o almeno `postgres` avviato), copiare la cartella del backup in `BACKUP_DIR`, poi la procedura sopra. Il `.env` deve avere gli stessi segreti dell'originale, `MEDUSA_PUBLISHABLE_KEY` compresa (la chiave sta nel DB Medusa ripristinato).
**Verificato in locale** (Docker Desktop, `DOMAIN=iti.localhost`, `.env` di prova con segreti casuali, progetto compose separato poi cancellato; per `*.localhost` Caddy usa la sua CA interna):
- build delle 5 immagini (medusa 693 MB, postgres 794 MB, api 288 MB, public 233 MB, caddy 64 MB); dai volumi vuoti: init Postgres, migrazioni Medusa e API all'avvio, seed Medusa nel container, `up -d --wait` con tutti i servizi healthy e solo Caddy con porte;
- `api.…/health` 200; `http://` → 308 https; `www` → 301; home, area, soluzione, scheda prodotto (prezzo da Medusa) 200, pagina inesistente 404; area riservata: `/`, `/admin/utenti/123`, `/ordini/5` → 200 (fallback SPA), `config.json` corretto e `no-store`; asset con hash `immutable`, compressione gzip/zstd;
- login sull'API con cookie `Domain=iti.localhost; Secure` e CORS dell'area; immagine caricata dall'admin servita da `media` (200); documento privato: da `media` 404 anche col percorso esatto, con `/private/…`, `../` e `%2e%2e`; via API 401 senza token e 200 con; il container Caddy non vede `private/`;
- dashboard Medusa: 401 senza credenziali o con password errata, 200 con basic auth, Store API e `/health` 404, login completo come sopra; whitelist con IP non ammesso → 403; `api.…/store/products` 404; webhook Stripe raggiungibile (Medusa risponde 200 e verifica la firma in modo asincrono);
- backup con rotazione (verificata con `KEEP=2`); **ripristino**: rifiutato con i servizi accesi; poi, dopo aver cancellato utenti, documenti, regioni Medusa e file, tutto tornato (proprietari `iti_app`, PostGIS presente, login admin, prodotti, immagine servita). Il primo ripristino ha mostrato il problema del `subpath` (immagine 404 finché Caddy non veniva riavviato): corretto nello script e riverificato senza riavviare Caddy.
- **Non verificati**: certificati Let's Encrypt reali e DNS (servono VPS e dominio); il sito nel browser con la CSP (Chrome non si fida della CA locale: controllato solo l'HTML servito); il dashboard Medusa nel browser (verificata la sequenza HTTP equivalente); il pagamento Stripe.
## Convenzioni di sviluppo
- Angular: standalone components, signals, zoneless, SCSS
- Fastify: plugin per auth/db/medusa-client, routes separate `public/` `private/` `admin/` (dettagli nella sezione `apps/api` sotto)
- Un concern per modulo/prompt, criteri di accettazione espliciti
- Dopo ogni step di implementazione, aggiornare questo file con lo stato corrente
## Ancora da decidere (non bloccante per l'avvio)
- Provider email transazionali specifico (Brevo vs Resend)
- Dettagli pipeline CI/CD (probabile GitHub Actions + deploy su VPS)
- Affinamento IVA/fatturazione elettronica (post-lancio)
- Implementazione concreta dello step 2 (assistente AI con RAG)
## Punti aperti dopo la security review (prompt 34, dettagli in `SECURITY_REVIEW.md`)
- **Stripe end-to-end** (pagamento, 3DS, carta rifiutata, webhook firmato) non verificato: servono le chiavi di test.
- **braces ≤ 3.0.3** nelle dipendenze di Medusa (77 avvisi *high* in cascata): non esiste una versione corretta e non è sfruttabile (nessun pattern da input esterno). Ricontrollare a ogni aggiornamento di Medusa. Il monorepo ha 0 vulnerabilità di produzione.
- **Rate limit in memoria**: con più istanze dell'API usare lo store Redis.
- **HTTPS reale** e certificati da verificare sulla VPS; `SHOP_ADMIN_ALLOWED_IPS` da restringere all'ufficio o alla VPN.
- **`iti_app_test`** creato anche in produzione dall'init: eliminarlo dopo il primo deploy.
- **GDPR**: cancellazione effettiva dei dati manuale; privacy e cookie policy da validare.
- Immagini esterne negli articoli del blog bloccate dalla CSP: vanno caricate dall'editor.
## Stato implementazione
I prompt di implementazione sono in `prompts/` (vedi `prompts/00-README.md`): vanno eseguiti **in ordine**, uno per sessione.
Alla fine di ogni prompt aggiornare la riga corrispondente (Stato: ⬜ da fare · 🟡 in corso · ✅ completato) e annotare nelle Note decisioni o deviazioni rilevanti.
| # | Fase | Prompt | Obiettivo | Stato | Note |
|---|---|---|---|---|---|
| 01 | Fondamenta | `01-monorepo.md` | Setup monorepo | ✅ | npm workspaces, `@iti/shared` ESM, Node 24, TS ~5.9 (poi 6.0 col prompt 22), git inizializzato (nessun commit) |
| 02 | Fondamenta | `02-docker-dev.md` | Docker Compose per sviluppo locale | ✅ | PGDG bullseye → apt-archive; init via `\getenv`; `.env` locale generato con password casuali |
| 03 | Medusa | `03-medusa-scaffold.md` | Scaffold Medusa | ✅ | Medusa 2.21.2, template turbo (backend in `medusa/apps/backend`); seed demo rimosso; moduli Redis |
| 04 | Medusa | `04-medusa-regione-stripe-iva.md` | Regione Italia, EUR, Stripe, IVA | ✅ | Prezzi IVA inclusa; Stripe con chiave **segnaposto** in locale (sostituire prima del 26); script in `medusa/apps/backend/src/scripts/` |
| 05 | Medusa | `05-medusa-installatori.md` | Customer group installatori e listino riservato | ✅ | Script separato `seed-installatori.ts`; gruppo trovato per `metadata.code`, non per id |
| 06 | Medusa | `06-medusa-route-interne.md` | Route interne per il gateway Fastify | ✅ | Customer Fastify con `has_account=true`; claim = request+accept transfer interno (email da verificare in `apps/api`); test in `http/internal.http` |
| 07 | Medusa | `07-medusa-subscriber-webhook.md` | Subscriber che notificano Fastify | ✅ | Id evento UUID v5 deterministico; retry in memoria, senza await (non blocca l'event bus); receiver di test `http/webhook-receiver.mjs` |
| 08 | API Fastify | `08-api-scaffold.md` | Scaffold API Fastify | ✅ | zod via `fastify-type-provider-zod`; log file con `pino-roll`; public/private senza prefisso, `/admin`, `/webhooks` |
| 09 | API Fastify | `09-api-schema-utenti.md` | Database e schema utenti | ✅ | Drizzle 0.45 + postgres.js; email minuscola + unique su lower(); token SHA-256, password argon2id |
| 10 | API Fastify | `10-api-schema-dominio.md` | Schema di dominio | ✅ | `immutable_unaccent` per la colonna generata; geography corretto a mano in 0002; quotes con year+seq; stati in `@iti/shared` |
| 11 | API Fastify | `11-api-auth.md` | Autenticazione JWT | ✅ | Route sotto `/auth` (cookie `Path=/auth`), `GET /me` alla radice; register sempre 202 (anti-enumerazione); test su `iti_app_test` |
| 12 | API Fastify | `12-api-medusa-client.md` | Client Medusa e sincronizzazione account | ✅ | Sync in background (mai bloccante); recupero a ogni login; claim solo con email verificata; 503 `SHOP_UNAVAILABLE` per Medusa giù |
| 13 | API Fastify | `13-api-proxy-shop.md` | Proxy shop (catalogo, carrello, checkout) | 🟡 | Implementato e testato con mock; **manca la verifica del pagamento Stripe reale** (inserire la chiave di test, poi `apps/api/http/shop.http`). Nuove route Medusa `/internal/carts/:id/guest` (evita che un guest prenda i prezzi di un account digitandone l'email) e `/internal/prices` |
| 14 | API Fastify | `14-api-webhook-medusa.md` | Ricezione webhook Medusa | ✅ | Firma verificata nel parser (prima del parse); idempotenza con lock `FOR UPDATE`; dettaglio ordine da nuova route `GET /internal/orders/:id`; email di spedizione senza tracking (`shipment_created` non inoltrato) |
| 15 | API Fastify | `15-api-email.md` | Servizio email | ✅ | Render subito + consegna in background con retry; trasporti console/brevo/resend da env; `html` tag con escaping; `npm run email:preview`; invio reale non verificato (provider da scegliere) |
| 16 | API Fastify | `16-api-storage-documenti.md` | Storage su disco e documenti | ✅ | Tipo file verificato su magic bytes + estensione + MIME; installer docs solo con profilo approvato; `/uploads` servito dall'API solo fuori produzione; hook admin unico in `routes/admin/index.ts` |
| 17 | API Fastify | `17-api-blog.md` | Blog | ✅ | Post programmati con `published_at` futuro; slug stabile al cambio titolo; sanitize-html con whitelist. Trovato e corretto bug zod 4 (`.default()` dentro `.partial()`) anche nel PATCH documenti del prompt 16 |
| 18 | API Fastify | `18-api-configuratore.md` | Configuratore a regole | ✅ | definition/rules/engine separati; tipi in `@iti/shared`; risposte a domande non visibili rifiutate; nuovo template `quote-request-received`; `AppError` con `details` |
| 19 | API Fastify | `19-api-preventivi-ticket.md` | Preventivi e ticket | ✅ | Numerazione con advisory lock; PDF preventivo privato scaricabile solo da `/me/quotes/:id/pdf`; richieste anonime collegate alla verifica email (non alla registrazione); migrazione `0003` `quotes.sent_at`; DTO `hasPdf` |
| 20 | API Fastify | `20-api-installatori.md` | Installatori e PostGIS | ✅ | Approvazione valida anche con Medusa giù (`medusaSynced`), gruppo riallineato al login; capoluoghi delle 107 province in `geo.ts`; coordinate limitate all'Italia |
| 21 | API Fastify | `21-api-contatti-showroom-consensi.md` | Contatti, showroom, consensi | ✅ | Anti-spam: honeypot + token firmato HMAC (tempo minimo 3 s) + rate limit; capienza slot con advisory lock; prenotazione confermata subito; orari showroom provvisori in `src/showroom/config.ts`; cancellazione account con password, dati cancellati a mano |
| 22 | App pubblica | `22-public-scaffold-ssr.md` | Scaffold Angular SSR | ✅ | Angular 22.2 zoneless, TypeScript di root portato a 6.0; configurazione runtime da env passata al browser via TransferState (nessun prerender); 404 con status reale, 301 sui duplicati; dati aziendali da completare in `core/site.ts`; Lighthouse SEO 100 |
| 23 | App pubblica | `23-public-home-soluzioni.md` | Home e pagine soluzioni | ✅ | Contenuti in `src/content/` (TS tipizzati, tutti segnaposto) con indice generato da `scripts/content-index.mjs`: nuova soluzione = nuovo file; gzip nel server Express (performance mobile 85 → 98); `?percorso=` per il configuratore e `?oggetto=` per i contatti |
| 24 | App pubblica | `24-public-blog.md` | Blog | ✅ | Categorie su `/blog/categoria/<slug>` (non query param: URL indicizzabili, in sitemap); `?q=` con debounce e `?pagina=`; ricerche noindex; 404/503 decisi dalla pagina con `injectResponseStatus`; `contentHtml` dell'API non risanitizzato da Angular |
| 25 | App pubblica | `25-public-catalogo-carrello.md` | Catalogo e carrello | ✅ | Sessione ritrovata con `/auth/refresh` solo se c'è il nuovo cookie indizio `iti_session` (API), refresh serializzati con Web Lock; `CartStore` in coda, solo browser, dopo la sessione; prezzi installatore ricaricati col token (`priceKey`), meta/JSON-LD solo con prezzi pubblici; categorie su `/shop/categoria/<handle>`; componenti di elenco condivisi col blog; budget iniziale 380 kB |
| 26 | App pubblica | `26-public-checkout.md` | Checkout con Stripe | 🟡 | Implementato e verificato fino al passo Pagamento; **mancano i 3 casi Stripe reali** (carta ok, 3DS, carta rifiutata): servono `sk_test_…` in `medusa/apps/backend/.env` e `pk_test_…` in `STRIPE_PUBLISHABLE_KEY` del sito. Fattura aziendale in `metadata.invoice` del carrello; consenso privacy su `/shop/cart/address`; conferma d'ordine da sessionStorage; Stripe.js caricato solo al pagamento |
| 27 | App pubblica | `27-public-configuratore.md` | Wizard configuratore | ✅ | Domande solo dalla definizione dell'API (visibilità con le stesse regole del server, in `core/configurator.ts`); preselezione `?percorso=` (alias `?esigenza=`) sulla prima domanda a scelta singola; stato in sessionStorage; wizard solo nel browser; ringraziamento su `/configuratore/grazie` (noindex) |
| 28 | App pubblica | `28-public-contatti-showroom-cookie.md` | Contatti, showroom, cookie consent | ✅ | Banner custom caricato con `@defer` solo se serve; `ConsentService` come unica fonte del consenso; cookie `iti_consent` 6 mesi; mappa OSM/Leaflet solo dopo il clic (asset copiati da `scripts/vendor-assets.mjs`), nascosta finché `COMPANY.geo` è null; anti-spam con attesa dei 3 s minimi lato client; privacy e cookie policy segnaposto "DA VALIDARE"; layout mobile non verificato nel browser |
| 29 | App privata | `29-private-scaffold-auth.md` | Scaffold e autenticazione | ✅ | SPA senza SSR, configurazione runtime da `/config.json` (da generare nel prompt 33); token in memoria, refresh deduplicato + Web Lock `iti-auth-refresh`, un solo nuovo tentativo su 401; `roleGuard` come `canMatch`, pagina iniziale per ruolo (`/`, `/installatori`, `/admin`); percorsi `/forgot-password`, `/reset-password`, `/verify-email` presi dai link dell'API; `?redirect=` validato contro gli open redirect; layout di accesso con `matcher` (con `path: ''` catturava `/`); SCSS condiviso spostato in `packages/styles`; sezioni 30-32 come segnaposto |
| 30 | App privata | `30-private-area-clienti.md` | Area clienti | ✅ | Nuove route API `PATCH /me`, `POST /auth/change-password` (chiude le altre sessioni, nuova sessione qui) e `GET /me/orders/:number` (filtro `display_id` aggiunto alla route interna Medusa); download protetti via HttpClient + object URL (il token non può stare in un link); `rxResource` con stati caricamento/vuoto/errore per ogni sezione; accetta/rifiuta con `<dialog>` nativo e `res.set()` della risposta; cancellazione account con password e dialogo |
| 31 | App privata | `31-private-area-installatori.md` | Area installatori | ✅ | `InstallerProfileStore` condiviso; listino e documenti tecnici caricati solo da approvati (avviso al posto del contenuto, senza `ng-content` che istanzierebbe comunque la pagina); `fromOriginalPrice` aggiunto al riepilogo prodotti dell'API; selettore su mappa Leaflet caricato su richiesta + campi lat/lng (accessibilità); `ITALY_BOUNDS` in `@iti/shared`; verificato con la ricerca PostGIS dell'admin |
| 32 | App privata | `32-private-admin.md` | Pannello admin | ✅ | Nuove route API `/admin/users` (ricerca, disabilita/riabilita con revoca delle sessioni e gruppo Medusa) e `/admin/summary`; editor markdown con barra minima, upload di immagini e anteprima live (400 ms) con gli stili del sito spostati in `packages/styles/_prose.scss`; catalogo solo dal dashboard Medusa (`medusaAdminUrl` in `config.json`); verificati nel browser articolo pubblicato = anteprima e approvazione installatore → listino e documenti con lo stesso token; layout mobile non verificato |
| 33 | Produzione | `33-produzione-caddy-compose.md` | Docker Compose e Caddy | ✅ | Dockerfile multi-stage (api, public, medusa; area riservata dentro l'immagine di Caddy); `config.json` dell'area generato da Caddy; uploads/public montato con `subpath` (private invisibile a Caddy); dashboard Medusa con whitelist IP + basic auth, esenti le chiamate `/auth/*` con Bearer del dashboard; webhook Stripe su `api.<dominio>`; migrazioni a ogni avvio; Medusa con `sslmode=disable`; backup/restore in container con rotazione, ripristino provato; CSP con `unsafe-inline` per gli script (da stringere nel 34); HTTPS reale non verificato (serve la VPS) |
| 34 | Produzione | `34-hardening-review.md` | Hardening e review di sicurezza | ✅ | Report in `SECURITY_REVIEW.md`. Corretti: admin riletto dal DB a ogni richiesta; niente più `'unsafe-inline'` negli script (sito con nonce per risposta dal server SSR via segnaposto `ngCspNonce`, area riservata senza script inline); `TRUST_PROXY` con proxy fidati + `X-Forwarded-For` inoltrato dall'SSR (prima il sito condivideva un solo rate limit); rate limit su upload admin e indirizzo del carrello; controlli di produzione su cookie Secure, CORS https, outbox email e segreti Medusa; hash anche alla registrazione con email esistente (tempi uniformi); lodash 4.18.1 in Medusa. Aperti: Stripe end-to-end, braces senza fix upstream, rate limit in memoria |