Deploy stack su Proxmox / deploy (push) Failing after 3s
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
1437 lines
210 KiB
Markdown
1437 lines
210 KiB
Markdown
# 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 `` 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 |
|