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

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

210 KiB
Raw Permalink Blame History

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):

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):

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):

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:

    {
      "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)eST_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.

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.

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`)
/installatori/listino (price-list.ts) GET /shop/products con il token tabella prodotto / listino (barrato) / tuo prezzo / sconto % / "Ordina" → siteUrl/shop/<handle>; ricerca ?q= e ?pagina= (20 per pagina). Prezzi IVA inclusa; senza prezzo riservato il prodotto mostra il listino con "(listino)"
/installatori/documenti (technical-docs.ts) GET /documents?limit=100 con il token solo i documenti con visibilità installer; filtro a pillole ?categoria= con le categorie presenti (assegnate dall'admin: manuali, CAD, certificazioni, listini…); download col token
  • InstallerProfileStore (core/installer-profile.store.ts, root): rxResource di GET /installer/profile (404 = profilo mai creato → missing), state (pending | approved | rejected | missing), approved, missing() (dati mancanti: P.IVA, telefono, indirizzo, posizione, certificazioni), save() (PUT, aggiorna il valore). Condiviso tra le pagine: la home e il listino non rileggono il profilo.
  • Installatore non approvato: listino e documentazione mostrano app-approval-gate (stato della richiesta, dati mancanti, link al profilo) e non fanno nessuna richiesta (params della risorsa undefined finché non è approvato). Le pagine usano @if (store.approved()) { … } @else { <app-approval-gate /> }: con un <ng-content> il contenuto proiettato verrebbe istanziato comunque (e la richiesta partirebbe). Il controllo vero resta nell'API (prezzi standard e niente documenti installer ai non approvati).
  • Prezzo di listino a confronto: ShopProductSummaryDto.fromOriginalPrice (nuovo campo dell'API) è il prezzo pubblico della stessa variante di fromPrice. Sconto = discountPercent(fromPrice, fromOriginalPrice).
  • Selettore su mappa (shared/location-picker.ts):
    • Leaflet 1.9 (build ESM leaflet-src.esm.js, chunk lazy), CSS da vendor/leaflet/leaflet.css copiato da scripts/vendor-assets.mjs, tile di tile.openstreetmap.org;
    • si carica solo con il pulsante "Scegli il punto sulla mappa" / "Mostra sulla mappa" (@defer dentro un @if), come la mappa del sito pubblico: prima nessuna richiesta a OpenStreetMap;
    • clic sulla mappa → punto (5 decimali) nei campi latitudine/longitudine; punto fuori dall'Italia → messaggio, campi invariati. Marcatore vettoriale (circleMarker) e cerchio del raggio di servizio, che segue il campo del raggio; la mappa inquadra il cerchio quando cambia il raggio;
    • accessibilità: latitudine e longitudine sono anche campi del form (accettano 45,58 o 45.58), sincronizzati con la mappa; "Rimuovi la posizione" li svuota;
    • validazione: entrambe o nessuna, dentro ITALY_BOUNDS (ora in @iti/shared, gli stessi confini dell'API).
    • CSP dell'area riservata (Caddyfile): img-src comprende https://tile.openstreetmap.org.
  • Menu dell'installatore: area installatore, listino, documentazione tecnica, profilo aziendale, poi ordini, preventivi, assistenza, "Altri documenti" (/documenti: quelli per tutti i clienti) e "Il mio account" (dove i dati aziendali rimandano al profilo aziendale).
  • Test (pages/installer/installer.spec.ts, 8 casi): dati mancanti del profilo; coordinate (formato italiano, una sola compilata, fuori Italia); corpo di PUT /installer/profile (trim, null, certificazioni senza duplicati); sconto e categorie; in attesa → nessuna richiesta di listino e documenti, avviso mostrato; approvato → listino con il token (5.490 € barrato, 4.650 €, −15%, link allo shop) e solo documenti installer filtrati per categoria. Test API: fromOriginalPrice nel catalogo dell'installatore (shop.test.ts).
  • Verificato nel browser (dev, API e Medusa reali, installatore registrato per la prova e poi cancellato):
    • in attesa: pagina con i 4 passaggi e i 5 dati mancanti; listino e documentazione con l'avviso e nessuna richiesta a /shop/products o /documents (controllato nel traffico di rete);
    • profilo: raggio non valido → errore; coordinate di Parigi → "La posizione deve essere in Italia"; certificazioni con un duplicato in minuscolo scartato; mappa aperta (tile, punto e cerchio da 40 km), clic sulla mappa → coordinate 45,77749 / 9,82775 nei campi; salvataggio → in DB service_radius_km = 40, punto 45.77749, 9.82775, certificazioni, stato pending;
    • ricerca admin /admin/installers/nearby: da Bergamo, in attesa → nessuno; dopo l'approvazione → l'installatore a 14,7 km (raggio 40); da Milano (~60 km) → escluso;
    • approvato: listino con i prezzi riservati reali di Medusa (4.650 / 990 / 8.400 € contro 5.490 / 1.190 / 9.900 €, −15/−17/−15%), ricerca "pompa" → 1 risultato, link allo shop del sito; documentazione con filtri Tutte/CAD/Manuali e filtro CAD funzionante; home con scorciatoie e riepilogo;
    • rifiutato via API admin: home "Richiesta non approvata" e listino non disponibile.
    • Dati di prova cancellati (utente, profilo, documenti e file, consensi); in Medusa resta il customer cus_01M41BW3W1Y4CPCR97WWRNJB37 (tolto dal gruppo installatori dal rifiuto).
    • Non verificati: layout mobile, uso della mappa da tastiera (c'è il fallback dei campi).

Pannello admin (prompt 32, pages/admin/; solo ruolo admin: roleGuard('admin') come canMatch sul ramo /admin, controllo vero nell'API con l'hook requireRole("admin")). Il catalogo (prodotti, prezzi, listini, giacenze, ordini) non è qui: si gestisce dal dashboard Medusa, con il link "Catalogo e ordini ↗" nel menu e un riquadro in dashboard (medusaAdminUrl).

Pagina Dati Note
/admin (admin-dashboard.ts) GET /admin/summary (nuovo), richieste new, visite da adesso 6 contatori cliccabili verso l'elenco filtrato (richieste nuove, ticket da gestire, installatori in attesa, visite in programma, contatti nuovi, preventivi in attesa di risposta), ultime richieste, prossime visite, link a Medusa
/admin/richieste[/:id] /admin/quote-requests elenco con filtro stato e ricerca. Dettaglio: contatti, messaggio, risultato del configuratore (soluzione, stime, avvertenze, prodotti, calcolo), risposte con le etichette della definizione attuale (GET /configurator/definition; senza definizione gli id, avviso se la versione è cambiata), installatori in zona (/admin/quote-requests/:id/installers, messaggio se manca la provincia), cambio stato, preventivi collegati e nuova bozza
/admin/preventivi[/:id] /admin/quotes elenco per stato. Dettaglio: dati, modifica solo in bozza, PDF (carica/sostituisci, scarica da /documents/:id/download), invio con dialogo (avvisa se manca il PDF), reinvio. La creazione parte da una richiesta o dalla scheda utente (QuoteForm; importo in formato italiano 7.450,50 → parseEuroToCents) e porta al dettaglio
/admin/ticket[/:id] /admin/tickets filtri stato e priorità, ricerca; conversazione, risposta (email al cliente) con lo stato da assumere (default "in attesa del cliente"), cambio stato/priorità
/admin/installatori[/:id] /admin/installers filtro stato e ricerca. Dettaglio: dati, zona (link a OpenStreetMap, nessuna mappa caricata), note interne, approva/rifiuta con dialogo (motivo nell'email di rifiuto), "Riallinea con lo shop" (/sync). medusaSynced: false → avviso
/admin/utenti[/:id] /admin/users (nuovo) filtri ruolo e stato, ricerca per email/nome/azienda. Scheda: account, profilo installatore, preventivi, nuovo preventivo, link ai ticket (?q=email), disabilita/riabilita con dialogo (non sul proprio account)
/admin/blog, /admin/blog/nuovo, /admin/blog/:id, /admin/blog/categorie /admin/blog/* elenco (stato "Programmato" per date future), editor (vedi sotto), categorie con aggiunta, modifica inline, eliminazione
/admin/documenti /admin/documents caricamento (file, titolo proposto dal nome, visibilità obbligatoria da scegliere, categoria, handle prodotto), filtro per visibilità, ricerca, modifica inline dei metadati, download, eliminazione con dialogo
/admin/contatti[/:id], /admin/showroom[/:id] /admin/contact-requests, /admin/showroom/bookings elenchi con filtri; dettaglio con cambio stato (StatusSelect). Showroom di default da oggi (?periodo=tutte anche le passate); "Confermata" invia l'email, SLOT_FULL con un messaggio dedicato
  • Convenzioni: elenchi in .data-table dentro .table-wrap (scorre in orizzontale su mobile); stato dei filtri nell'URL (?stato=, ?q=, ?pagina=; 20 per pagina, documenti 50); componenti shared/list-filters.ts (app-filter-pills, app-search-box); etichette degli stati lato staff in core/labels.ts (ADMIN_TICKET_STATUS, ADMIN_QUOTE_STATUS, QUOTE_REQUEST_STATUS, INSTALLER_STATUS, CONTACT_STATUS, BOOKING_STATUS, BLOG_STATUS, VISIBILITY_LABELS, optionsOf). Ogni azione aggiorna la pagina con la risposta dell'API (res.set), errori in un .alert. Upload multipart con FormData (pages/admin/upload.ts).
  • Editor del blog (blog-editor.ts|html|scss, funzioni pure in markdown-edit.ts):
    • textarea markdown con barra minima: grassetto, corsivo, H2, H3, elenco, citazione, link, immagine (upload su POST /admin/uploads/image e inserimento di ![Descrizione dell'immagine](url) su una riga a sé, con il testo alternativo selezionato da sostituire). Scorciatoie Ctrl/Cmd+B, I, K. Ogni azione rimette il focus nella textarea con la parte da completare selezionata;
    • anteprima live: POST /admin/blog/preview dopo 400 ms di pausa (debounceTime + switchMap), stesso renderer e sanitizer del sito; l'HTML precedente resta visibile mentre arriva il nuovo. Mostra titolo, estratto, data, categoria, copertina e corpo con i mixin di _prose.scss. Scrittura e anteprima sono affiancate quando il pannello è largo almeno 56rem (container query), altrimenti ci sono le schede "Scrivi / Anteprima";
    • colonna laterale: bozza/pubblicato, data di pubblicazione (datetime-local in ora locale; futura = programmato), categoria, copertina (upload, sostituzione, rimozione), SEO (titolo ≤ 70 e descrizione ≤ 160 con contatori), slug (facoltativo alla creazione; in modifica inviato solo se cambiato, con avviso sui link condivisi; SLUG_TAKEN sul campo), elimina;
    • dopo la creazione si passa a /admin/blog/:id (replaceUrl); link "Vedi sul sito" quando è pubblicato e la data è passata.
  • Test (pages/admin/admin.spec.ts, 11 casi): importi in euro, azioni della barra markdown, immagine inserita, datetime-local ↔ ISO, corpo dell'articolo (vuoti → null, slug), stato programmato, risposte del configuratore con etichette, metadati dei documenti; editor: digitazione rapida → una sola richiesta di anteprima dopo la pausa, HTML in .prose; grassetto sulla selezione e salvataggio come pubblicato → navigazione all'articolo; installatore: approva → dialogo, POST, stato aggiornato, avviso con medusaSynced: false.
  • Verificato nel browser (dev, API, Medusa e sito reali):
    • login admin → dashboard con i contatori (1 installatore in attesa);
    • editor: titolo, estratto, testo con H2 dalla barra e grassetto con Ctrl+B, anteprima affiancata aggiornata; pubblicato → /blog/prova-editor-addolcitore-e-calcare sul sito con lo stesso HTML dell'anteprima e gli stessi stili calcolati (corpo 18 px, h2 32 px, larghezza 72ch);
    • installatore registrato per la prova: prima prezzi standard (549000) e nessun documento; approvato dal pannello → con lo stesso token prezzo riservato 465000 (listino 549000) e il documento "installatori" caricato dal pannello (senza visibilità scelta → errore);
    • utente: ricerca, scheda, nuovo preventivo 7.450,50 → bozza 2026-0001, PDF caricato, inviato (stato "Inviato");
    • categorie, ticket, showroom (filtri) senza errori in console. Gli elenchi con dati di ticket, contatti, showroom e richieste sono verificati solo dai test e dallo stato vuoto (il DB di sviluppo non ne ha).
    • Dati di prova cancellati (articolo, documenti e file, preventivo, utente); in Medusa resta il customer cus_01M4632M63A3D0828HATXM5K3V, tolto dal gruppo installatori.
    • Non verificati: upload di immagini nell'editor dal browser (coperto dalle funzioni pure e dai test dell'API), layout mobile.
  • Build: bundle iniziale 338 kB (95 kB compressi; era 320 kB); editor 28 kB, le altre pagine admin 3-13 kB.

Test (core/auth.spec.ts, 19 casi):

  • sessione: nessun refresh senza cookie indizio; sessione ritrovata col cookie e token assente dagli storage; refresh rifiutato → anonimo; login con credenziali;
  • interceptor: bearer solo verso l'API; token scaduto con 3 richieste parallele → 1 refresh; 401 su 2 richieste parallele → 1 refresh e un nuovo tentativo ciascuna; refresh fallito → logout e login con scaduta;
  • guard: customer su /admin → /; admin su /ordini → /admin; installatore su / → /installatori, ma con accesso ai preventivi; company fuori da /installatori; anonimo → login con redirect; autenticato su /login → sua area;
  • redirect validato (open redirect, javascript:, //, /\); config.json; corpo della registrazione.

Verificato nel browser (dev, API reale):

  • anonimo su /admin/utenti → /login?redirect=%2Fadmin%2Futenti;
  • registrazione azienda: errori a form vuoto (ragione sociale e P.IVA comprese), poi 202 → utente company con ragione sociale e P.IVA in DB;
  • login con ?redirect=/preventivi/abc → quella pagina, menu clienti, avviso email da confermare; nessun token in localStorage o sessionStorage;
  • refresh della pagina → sessione mantenuta (un solo POST /auth/refresh);
  • utente azienda su /admin/installatori → / (Panoramica);
  • link di verifica da loggato → "Email confermata", token tolto dall'URL, avviso sparito;
  • da loggato, /login?redirect=https://evil.example.com/ → /; ?redirect=http://localhost:4200/carrello → pagina del sito, che ritrova la stessa sessione (in dev i cookie di localhost valgono per tutte le porte);
  • "Esci" → /login; poi /ordini → login senza nessuna richiesta all'API.
  • Dati di prova cancellati dal DB applicativo. In Medusa resta il customer cus_01M419PVYHKC33FVWKYWMXBQ4R (azienda-p29-…@example.com).
  • Non verificati nel browser: refresh trasparente a token scaduto e 401 concorrenti (coperti dai test), login admin e installatore (coperti dai test dei guard), layout mobile.
  • Build di produzione: bundle iniziale 292 kB (80 kB compressi); pagine di accesso in chunk lazy (registrazione 9 kB).
  • Prompt 30: bundle iniziale 309 kB (86 kB compressi); pagine dell'area clienti in chunk lazy (profilo 14 kB, le altre 3-7 kB).
  • Prompt 31: bundle iniziale 320 kB (90 kB compressi: crescono i chunk comuni precaricati, come i form); pagine installatori 5-14 kB, Leaflet 149 kB nel suo chunk, caricato solo aprendo la mappa.

apps/api (Fastify)

Package @iti/api: ESM, TypeScript strict (estende tsconfig.base.json, NodeNext → import relativi con estensione .js), Fastify 5, Node 24.

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 frammentiSafeHtmlprodotti dahtml` 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):

    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):

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