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
210 KiB
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) conworkspaces: ["apps/*", "packages/*"].medusa/è escluso: ha il suopackage.jsonenode_modules, si installa a parte. - Node 24 LTS (
.nvmrc,engines.node >= 24), npm 11. TypeScript~6.0come 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/apiepackages/sharedcompilano senza modifiche (typecheck, build e 207 test verificati). tsconfig.base.json: strict +noUncheckedIndexedAccess,exactOptionalPropertyTypes,noImplicitOverride; target ES2022,module/moduleResolutionNodeNext. Ogni package la estende (le app Angular potranno sovrascriveremodule/moduleResolutionconbundler).- Script root:
npm run build(tutti i workspace con scriptbuild),npm run build:shared. - Finché un'app non ha un
package.json, la cartellaapps/<nome>/contiene solo.gitkeepe 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(mixinprose.containereprose.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 daapps/public/src/stylesestyles.scssnel prompt 29.- Le due app hanno
stylePreprocessorOptions.includePaths: ["../../packages/styles"]inangular.json: nei componenti basta@use 'tokens'/@use 'mixins' as mq, e lostyles.scssdi 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 contscindist/(JS +.d.ts), entryexports["."]→dist/index.js. Import relativi interni con estensione.js(richiesto da NodeNext). - Va buildato prima di usarlo dalle app: lo fanno in automatico
postinstalldi root eprebuilddiapps/api; dopo modifiche manualinpm 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 traapi,publiceprivate: niente logica runtime con dipendenze. - Consumo dalle app: dipendenza
"@iti/shared": "*"nelpackage.jsondell'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 custominfra/postgres/Dockerfile=postgis/postgis:16-3.4+postgresql-16-pgvector(0.8.x). Volumepostgres-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 immaginipostgis/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\getenve crea i ruoliAPP_DB_USER/MEDUSA_DB_USER, i databaseAPP_DB_NAME(iti_app),<APP_DB_NAME>_test(iti_app_test, per i test automatici diapps/api) eMEDUSA_DB_NAME(iti_medusa) ciascuno di proprietà del proprio utente,CONNECTrevocato aPUBLIC(un utente non accede al DB dell'altro). Initi_appeiti_app_testcrea le estensionipostgis,vector,pg_trgm,unaccent. - Healthcheck
pg_isready -h 127.0.0.1: diventa healthy solo a init completato.
- L'immagine base è su Debian bullseye: il repo PGDG originale risponde 404, quindi il Dockerfile punta a
- redis (
redis:7-alpine, portaREDIS_PORT, default 6379, AOF attivo): volumeredis-data, healthcheckredis-cli ping. URL inREDIS_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→ 200OK. - 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→ databaseiti_medusacon utenteiti_medusa(credenzialiMEDUSA_DB_*del.envdi root).REDIS_URLusato daprojectConfig.redisUrle dai moduli Redis consigliati dalla guida di deployment:caching(providercaching-redis),event-bus-redis,workflow-engine-redis,locking(providerlocking-redis). Tutti sullo stesso Redis.- In produzione (
NODE_ENV=production) la config rifiuta di partire seJWT_SECRET,COOKIE_SECRET,INTERNAL_API_SECREToWEBHOOK_SECRETmancano o hanno meno di 32 caratteri (senzaJWT_SECRETMedusa userebbesupersecret). Avvisa se c'èSTRIPE_API_KEYsenzaSTRIPE_WEBHOOK_SECRET. - CORS da env:
STORE_CORS,ADMIN_CORS,AUTH_CORS. Il frontend non chiama Medusa (passa daapps/api), quindiSTORE_CORSresta minimale. - Segreti:
JWT_SECRET,COOKIE_SECRET,AUTH_MFA_ENCRYPTION_KEY(generati casuali nel.envlocale). - File: modulo
@medusajs/medusa/filecon providerfile-local; cartellaFILE_UPLOAD_DIR(defaultstatic, relativa al backend) e URL pubblicoFILE_BACKEND_URL(defaulthttp://localhost:9000/static). Medusa serve sempre e solo<backend>/staticsu/static: se in produzioneFILE_UPLOAD_DIRpunta a un volume diverso, quel volume va servito da Caddy eFILE_BACKEND_URLaggiornato.
Modifiche al template:
- Rimossi
src/migration-scripts/initial-data-seed.ts(veniva eseguito dadb:migratee creava regione "Europe", USD, spedizioni e prodotti demo) esrc/scripts/seed-demo-products.ts; rimossi gli scriptbackend:seedestorefront:dev. Il databaseiti_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_CURRENCIESinsrc/search/helpers/pricing.tsridotto a["eur"]. medusa/package.jsonimpostatoprivate: 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 providerpp_stripe_stripe; - tax region
it(providertp_system) con aliquota di default IVA 22% (codiceIVA22, provvisoria); - sales channel "Web" + publishable API key "Web (apps/api)" collegata → va in
apps/api/.envcomeMEDUSA_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_total990 (IVA scorporata 22/122), spedizione 15 € con IVA,total5.505.
Stripe:
- Provider
@medusajs/medusa/payment-stripe, idstripe→ provider IDpp_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, quindimedusa-config.tslo registra solo seSTRIPE_API_KEYè impostata (altrimenti warning);seed-base.tssi 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 diapi.<dominio>), con gli eventipayment_intent.amount_capturable_updated,payment_intent.succeeded,payment_intent.payment_failed,payment_intent.partially_funded. Il signing secret (whsec_...) va inSTRIPE_WEBHOOK_SECRET. In locale si può usarestripe listen --forward-to localhost:9000/hooks/payment/stripe_stripe. - ⚠️ Nel
.envlocaleSTRIPE_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/apideve trovarlo per codice (gruppo conmetadata.code = "installer", oppure per nomeinstallatori) o leggerlo da env, mai hardcodarlo. - Price list "Listino installatori":
type: override,status: active, regolacustomer.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
createPriceListsWorkflownon esponetype, ma lo step lo inoltra al Pricing Module (comePOST /admin/price-lists); senza, il default sarebbesale. - 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}/customerscon body{ "add": ["cus_..."] }(o{ "remove": [...] }), autenticata come utente admin Medusa (Authorization: Bearer <token>daPOST /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.tsregistrato insrc/api/middlewares.tssu/internal*. Headerx-internal-secret=INTERNAL_API_SECRET(stesso valore inmedusa/apps/backend/.enveapps/api/.env). Confronto contimingSafeEqualsui 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) → 400invalid_data. Le email vengono normalizzate con trim + minuscolo:apps/apideve usare la stessa normalizzazione. - Errori Medusa standard: 404
not_foundper 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 }, oemailalla creazione) passa dafindOrCreateCustomerStep, che cerca per email senza filtrarehas_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 questoapps/apinon passa maiemailalla 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: nelcalculated_priceMedusa riporta comeoriginal_amountil prezzo del listino stesso, non quello pubblico. Il prezzo pubblico è ilcalculated_pricecalcolato senza contesto customer. - Customer account vs guest: Medusa ammette un customer per coppia
(email, has_account). Il checkout guest crea customer conhas_account=false; quelli creati da Fastify hannohas_account=true. Questo è necessario:requestOrderTransferWorkflowrifiuta 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/apideve 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, ambientedev). Le variabili pubbliche stanno inhttp-client.env.json, i segreti inhttp-client.private.env.json(gitignored; modello inhttp-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
OrderWorkflowEventsdi Medusa 2.21):Evento Emesso da dataoriginaleorder.placedcompleteCartWorkflow(checkout), conversione draft order{ id }order.canceledcancelOrderWorkflow{ id }order.fulfillment_createdcreateOrderFulfillmentWorkflow{ 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;nullse non trovato).occurred_at= istante in cui il subscriber elabora l'evento. -
idevento: UUID v5 deterministico danome evento + data originale. Lo stesso evento ha sempre lo stesso id, anche se il subscriber viene rieseguito, ed è identico in tutti i retry.apps/apideduplica su questo id (webhook_events, prompt 14). -
Firma: header
x-signature= HMAC-SHA256 in esadecimale minuscolo del body grezzo (i byte esatti inviati) con chiaveWEBHOOK_SECRET(stesso valore inmedusa/apps/backend/.enveapps/api/.env). Lato Fastify: calcolare l'HMAC sul raw body prima del parse JSON e confrontare contimingSafeEqual. Esempio di riferimento inmedusa/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 è unwarnnei log, l'abbandono è unerrorcon 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_URLoWEBHOOK_SECRETl'invio viene saltato con un warning. -
Ricevitore:
POST /webhooks/medusadiapps/api(prompt 14, vedi sezioneapps/api). Receiver di test standalone, utile per provare il subscriber senza l'API:node http/webhook-receiver.mjs 3000inmedusa/apps/backend. Verifica la firma e stampa le richieste; conFAIL=nrisponde 503 alle prime n. -
Verificato: ordine (draft convertito) →
order.placedfirmato; conFAIL=2→ 503, 503, 200 al 3° tentativo (backoff 2 s, 4 s);order.fulfillment_createdeorder.canceledconsegnati e firmati. Con l'endpoint giù l'ordine si completa in ~0,6 s e i 5 tentativi diorder.placedeorder.fulfillment_createdcompaiono nei log in parallelo, fino all'abbandono. -
⚠️ In sviluppo, prima di riavviare Medusa assicurarsi che le istanze precedenti di
turbo dev/medusa developsiano 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.
- Login/registrazione → sempre verso Fastify, JWT proprio
- Fastify crea prima la riga in
users(Postgres), poi il customer Medusa corrispondente via Admin API, mappato conusers.medusa_customer_id - Operazioni shop (carrello/checkout) → Fastify fa da proxy verso Medusa usando quella mappatura
- 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 generataSTOREDcon indice GIN. Query:search_vector @@ websearch_to_tsquery('italian', unaccent($q)), ordinamento conts_rank.immutable_unaccent(text)è un wrapper IMMUTABLE diunaccent(le colonne generate non accettano funzioni STABLE), creato dalla migrazione custom0001_immutable_unaccent.sql. - Geografia: custom type
geographyPoint(columns.ts). In TypeScript è{ lat, lng }(GeoPointdi shared): in scrittura diventaST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography, in lettura viene convertito da EWKB. Per i calcoli usare la colonna insql\…`:ST_DWithin(location, punto, service_radius_km * 1000)eST_Distance` (metri). - Numero preventivo:
year+seq(unique) enumber=${year}-${seq a 4 cifre}; la generazione (prompt 19) prendemax(seq)+1dell'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). In0002_domain.sqlè stato corretto a mano; se una futura migrazione tocca la colonnalocation, 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,notesadmin, payload). Le route dei prompt successivi possono rifinirli.- Ora contiene anche codice runtime (le costanti), quindi
apps/apilo importa dadist/: viene compilato dapostinstalldi root (dopo ogninpm install) e daprebuilddiapps/api. Dopo una modifica apackages/sharedlanciarenpm run build:shared(oppurenpx tsc -p packages/shared -wmentre 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 sonoexport defaulte si caricano conloadComponent: () => import('./pages/x'). - TypeScript:
tsconfig.jsonestendetsconfig.base.json(strict,noUncheckedIndexedAccess,exactOptionalPropertyTypes) conmodule: preserve,moduleResolution: bundler, lib DOM;strictTemplatesed extended diagnostics come errori. - Stili: i token SCSS diventano custom properties su
:root(--color-primary,--space-4,--font-size-xl…) e i componenti usanovar(--…). 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-ITregistrato (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 inpublic/images/aree/). - Come funziona:
scripts/content-index.mjsscansiona le cartelle e scrivesrc/content/generated/(paths.tscon i soli slug per route e sitemap;areas.ts,solutions.ts,company.tscon gli import). Il builder Angular (esbuild) non supportaimport.meta.glob, per questo serve il generatore. Parte da solo inpostinstall,predev,prebuild,pretestepretypecheck; la cartella è in.gitignore. Connpm run devgià avviato, dopo aver aggiunto un file lanciarenpm 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 sugenerated/paths.ts, senza importare i contenuti. - Testi: le
descriptionstanno 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 inpublic/conaltdescrittivo e dimensioni intrinseche (perNgOptimizedImage). - Prodotti correlati:
relatedProductscontiene handle Medusa. La card leggeGET /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àpercorsocon 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;prodottodalla 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, poisetStatus(404)in uneffectquando arriva la risposta. Funziona perché il server attende le richieste HTTP in corso prima di inviare la risposta. Contenuto con<app-not-found-view>esetPage({ …, 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): tutteRenderMode.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, defaultPUBLIC_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) eNG_ALLOWED_HOSTS(letta da Angular). Modello inapps/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ì (fallbackenvironment.ts).API_BASE_URL: sul server èAPI_INTERNAL_URL(es.http://api:3000in Docker), nel browserPUBLIC_API_URL; se differiscono,HTTP_TRANSFER_CACHE_ORIGIN_MAPfa coincidere le chiavi della transfer cache.SITE_INDEXABLE=truesolo in produzione: altrimenti tutte le pagine hannorobots: noindex, nofollowerobots.txtèDisallow: /.- Host ammessi (protezione SSRF di Angular SSR):
angular.json→security.allowedHostscontiene sololocalhost/127.0.0.1; in produzioneNG_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 conservaqe si torna a pagina 1. - 404 reali: categoria inesistente (verificata su
GET /blog/categories),paginanon valida (abc,0,01) o oltre l'ultima, articolo inesistente, in bozza o programmato (POST_NOT_FOUNDdell'API).pagina=1è accettato, con canonical senza parametro. - Ricerca: form
role="search"conmethod="get", quindi funziona anche prima dell'idratazione o senza JavaScript. Nel browser, digitando, aggiorna?q=dopo 350 ms di pausa conreplaceUrl(niente voce di cronologia per ogni pausa) escroll: '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. Messaggiorole="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". Ricerchenoindex. Categorie con description propria (o generata) e BreadcrumbList. - SEO articolo: title
seoTitle ?? title, descriptionseoDescription ?? 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:
contentHtmldell'API inserito conbypassSecurityTrustHtml. L'API (sanitize-html con whitelist) resta l'unico sanitizer: quello di Angular toglierebbeloading="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,
NgOptimizedImagefill+priority), data, categoria (link alla pagina della categoria), CTA verso il configuratore (QuoteCtaconshowConfigurator, 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 dilocalhostvalgono per tutte le porte). - Cookie indizio
iti_session=1(aggiunto all'API in questo prompt): impostato insieme aiti_rta login e refresh, con la stessa scadenza e lo stesso dominio, ma non httpOnly e conPath=/, così l'app lo legge dadocument.cookie. Viene cancellato coniti_rtal logout e al refresh fallito. Non contiene segreti: è solo un'ottimizzazione, l'autorità resta il refresh token. - All'avvio, nel browser, dopo l'idratazione (
afterNextRenderinapp.ts):- 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; - con
iti_session→POST /auth/refreshcon credenziali → access token in memoria (mai in storage) eUserDto. Refresh fallito → anonimo.
- senza
ApiServiceha l'opzioneauth: true(usata da/shop/*): l'interceptor aggiungeAuthorization: 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.priceKeyvale"public"finché la sessione non è nota e per chi non è installatore,installer:<id>per un installatore: va neiparamsdelle 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, nomeiti-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 enoindex, 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 suvisibility = public(il link di download non può portare il token). Link diretto aGET /documents/:id/download; - SEO: title, description (sottotitolo + descrizione,
plainTextExcerpt),og:type product,og:image, canonical, JSON-LD Product (unOfferper variante con prezzo:price"5490.00",EUR,InStock/OutOfStock,sku) + BreadcrumbList. Meta e JSON-LD solo conpriceType: 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()inapp.tsdopo l'idratazione, una volta), dopoSessionService.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(cookiecart_id) eauth: true.CART_NOT_FOUNDin 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).
- si carica solo nel browser (
- 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.Serverperché la configurazione runtime arriva col TransferState (RenderMode.Clientuserebbe i default dienvironment.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):
- 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. - 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). - Spedizione: opzioni da
GET /shop/cart/shipping-options, scelta attuale preselezionata. - 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 }).
- Contatti: email per l'ospite, con link "Accedi" →
- 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_errordi 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 maPOST /shop/cart/completefallito → "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>/confermatoconreplaceUrl. 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/purecon 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 conaria-describedby/aria-invalid, mostrato dopo il blur o l'invio, messaggi personalizzabili,{ server: msg }per gli errori dell'API) eshared/address-fields.ts. Stili globali instyles.scss(.form-grid,.form-field,fieldset,.check,.alert), da riusare nei form del prompt 28. ⚠️ Un<form>senza[formGroup]non emettengSubmit(NgForm sta inFormsModule): usare(submit)="$event.preventDefault(); …". formatEuromostra sempre 2 decimali quando l'importo ha centesimi (992,70 €, prima992,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 (
sdiCodemaiuscolo, P.IVA normalizzata) e consenso privacy registrato una volta (source = checkout), spedizione 15 € → totale 5.505 €, ricarica → ripresa dal passo Pagamento, senzaSTRIPE_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 evisibleIfarrivano daGET /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@casenel template. - Visibilità e risposte (
wizardState): stesse regole divalidateAnswersdell'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.parseNumberaccetta 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 oresult), salvato a ogni modifica. Al refresh si riprende dallo stesso step, oppure dal primo step incompleto se la definizione è cambiata. Sul risultato si ricalcola conevaluate. 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" (detailsin un<details>), incentivi, disclaimer dell'API (stima indicativa), prodotti suggeriti conRelatedProducts(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/submitconauth: 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 sonofieldset/legendcon radio e checkbox native (tastiera nativa), opzioni a schede ≥ 44 px con focus visibile (:has(input:focus-visible)).aria-invalidearia-describedbycollegano 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 condizioniin/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 ringraziamentonoindex. Tutti e 4 i percorsi arrivano al risultato e a unaquote_requestsalvata con consenso (source = configurator; dati di prova poi cancellati). Idratazione senza errori. - Chunk lazy
configurator31 kB; bundle iniziale invariato (366 kB).
Contatti, showroom e cookie (prompt 28):
- Banner cookie (
layout/cookie-banner.ts, nessuna libreria esterna). Montato inapp.tsdentro@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 sonogranted('statistics' | 'marketing')(reattivo),whenGranted(cat)(promise),acceptAll,rejectAll,save,openPreferences.- Sul server e prima di
init()(inapp.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.
- Sul server e prima di
- Cookie
iti_consent(core/consent.ts): valore<versione>.<stat 0|1>.<mkt 0|1>.<secondi>,Path=/,Max-Age6 mesi (poi il banner ricompare),SameSite=Lax,Securesu https, senzaDomain(solo l'host del sito), non httpOnly. CambiandoCONSENT_VERSIONle scelte decadono. - Cookie tecnici del sito:
iti_consent,iti_rteiti_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, inCOOKIE_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/tokenchiesto dopo il render; - se l'utente invia prima dei 3 s minimi,
FormGuard.submitattende invece di farsi scartare in silenzio dall'API; FORM_EXPIRED→ nuovo token e un solo nuovo tentativo;- honeypot
websitefuori schermo,tabindex="-1",aria-hidden.
- token da
- 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 disoluzione(area o soluzione dai contenuti) o diprodotto(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 (ogginull, quindi la sezione non c'è). È un pulsante "Mostra mappa": solo dopo il clic un@defercaricashared/osm-map.ts, con Leaflet 1.9 nella build ESM (leaflet/dist/leaflet-src.esm.js, tipi inshared/leaflet-esm.d.ts, chunk lazy da 37 kB compressi), il CSS (/vendor/leaflet/leaflet.css, aggiunto con un<link>) e le tile ditile.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, mentrenode_modulessta nella root del monorepo. Per questoscripts/vendor-assets.mjsli copia inpublic/vendor/(inpostinstall,predev,prebuild). - ⚠️ Policy delle tile OSM: va bene per traffico modesto; con molte visite valutare un provider di tile.
- Form: nome, email, telefono, oggetto (facoltativi gli ultimi due), messaggio (10-5000 caratteri), consensi, honeypot →
/showroom(pages/showroom.ts|html|scss, contenuti incontent/showroom.ts, funzioni pure incore/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 pulsantiaria-pressedin 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.
- Contenuti: testi, punti di forza, orari (
/privacye/cookie(shared/legal-page.ts, testi incontent/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.
- formato del cookie, versione, attributi e assenza di
- 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-confirmedgenerata (.outbox), posti dello slot da 6 a 4, consensosource = 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.htmlhangCspNonce="__CSP_NONCE__"sul componente radice. La build copia il segnaposto comenoncesu script e stili inline (contratto dell'event replay, CSS critici di beasties). Durante l'SSR lo usa anche lo script__jsaction_bootstrap(CSP_NONCElegge 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-srcAPI e Stripe,img-srcPUBLIC_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 dagenerated/paths.ts, sezioni, privacy, cookie; niente carrello/checkout né URL duplicati) + categorie del blog (/blog/categoria/<slug>) e articoli daGET {API_INTERNAL_URL}/sitemap-dataconlastmod. 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 daGET /shop/products(pagine da 50, max 5000) eGET /shop/categories; con Medusa giù la sitemap esce senza lo shop e si riprova dopo 60 s./robots.txt: in produzioneAllow: /,Disallow: /carrello,/checkoute/ordine/, rigaSitemap:; altrimentiDisallow: /.
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 deve build SSR senza warning (initial 312 kB, 87 kB compressi).curlsulla build avviata conSITE_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-siamoe/soluzioni301; carrellonoindex; 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
NgOptimizedImagesu 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=3oltre l'ultima,?pagina=abc, categoria inesistente, slug inesistente,/blog/categoria→ 404; - HTML del server: title, description (
seoDescriptiono estratto dal markdown), canonical,rel prev/next, ricercanoindexsenza 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.
- status:
- 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=2oltre 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;/carrellocon "Caricamento del carrello…" enoindex; 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
fetcha/auth/login, come farà l'area riservata):iti_sessionpresente →/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
publice unoinstallercollegati al prodotto via API admin (poi cancellati) → in pagina solo il pubblico, download 200application/pdf; - per il test l'installatore e2e
inst-e2e-1790970518032@example.comdel DB di sviluppo ha ora una password nuova (non salvata nel repo).
- status:
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.tslegge/config.jsonprima del bootstrap (loadRuntimeConfig):{ "apiUrl": "https://api.<dominio>", "siteUrl": "https://<dominio>", "medusaAdminUrl": "https://shop-admin.<dominio>/app" }. TokenAPP_CONFIG.medusaAdminUrl(defaulthttp://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.jsonnon è un file: lo risponde Caddy (area.{$DOMAIN}/config.json,Cache-Control: no-store) costruendolo daDOMAIN(https://api.…,https://…,https://shop-admin.…/app). Il Dockerfile di Caddy cancella un eventualeconfig.jsondalla 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 httpOnlyiti_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.tsmostra "Caricamento dell'area riservata…". refresh()è deduplicato (una sola promise in volo) e gira sotto il Web Lockiti-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/loginconwithCredentials, 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/*usanoSKIP_AUTH(inApiService: opzioneanonymous: 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):
- 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; - 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;
- 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".
ApiServicehaget/post/put/patch/deleteed erroriApiErrorcome 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 senzaredirecte quando si apre una sezione non permessa. authGuard(sul layout autenticato): senza sessione →/login?redirect=<url>.roleGuard(...ruoli)comecanMatchsu 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).
- Ruolo non ammesso →
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 primoawait: 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, nonpath: ''. Una rotta vuota con figli corrisponde anche all'URL/quando nessun figlio corrisponde, e/mostrava il layout di accesso vuoto (trovato dal test).authPagesMatcherlo limita ai 5 percorsi di accesso: una nuova pagina di accesso va aggiunta adAUTH_PAGES. ?redirect=validato (core/redirect.ts, contro gli open redirect). Ammessi:- percorso dell'app (non
//hostné/\host); - URL assoluto dell'app;
- URL assoluto con la stessa origine di
siteUrl(navigazione esterna conlocation.assign). - Tutto il resto, e le pagine di accesso stesse, →
homeFor(ruolo).
- percorso dell'app (non
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);
registerBodyinvia 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.
- tipo di account come radio a schede; nome, cognome, telefono facoltativo, email, password (≥ 10) ripetuta, privacy obbligatoria (link a
- 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 mostraapp-loading(conrole="status"),app-empty(messaggio e azione) oapp-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 conHttpClient(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 daContent-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 conshowModal()(focus intrappolato, Esc = Annulla, bloccato mentre l'operazione è in corso), variantedanger. - 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=disattivatocon un messaggio. Password errata → errore sul campo, account intatto.
- Dati personali: nome e cognome; ragione sociale e P.IVA solo per
- Stili globali aggiunti in
styles.scss:.page-head,.back-link,.section,.item-list/.item(righe cliccabilia.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 diPATCH /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"; documentocustomernella 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):rxResourcediGET /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 (paramsdella risorsaundefinedfinché 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 documentiinstallerai non approvati). - Prezzo di listino a confronto:
ShopProductSummaryDto.fromOriginalPrice(nuovo campo dell'API) è il prezzo pubblico della stessa variante difromPrice. Sconto =discountPercent(fromPrice, fromOriginalPrice). - Selettore su mappa (
shared/location-picker.ts):- Leaflet 1.9 (build ESM
leaflet-src.esm.js, chunk lazy), CSS davendor/leaflet/leaflet.csscopiato dascripts/vendor-assets.mjs, tile ditile.openstreetmap.org; - si carica solo con il pulsante "Scegli il punto sulla mappa" / "Mostra sulla mappa" (
@deferdentro 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,58o45.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-srccomprendehttps://tile.openstreetmap.org.
- Leaflet 1.9 (build ESM
- 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 diPUT /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 documentiinstallerfiltrati per categoria. Test API:fromOriginalPricenel 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/productso/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, punto45.77749, 9.82775, certificazioni, statopending; - 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).
- in attesa: pagina con i 4 passaggi e i 5 dati mancanti; listino e documentazione con l'avviso e nessuna richiesta a
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-tabledentro.table-wrap(scorre in orizzontale su mobile); stato dei filtri nell'URL (?stato=,?q=,?pagina=; 20 per pagina, documenti 50); componentishared/list-filters.ts(app-filter-pills,app-search-box); etichette degli stati lato staff incore/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 conFormData(pages/admin/upload.ts). - Editor del blog (
blog-editor.ts|html|scss, funzioni pure inmarkdown-edit.ts):- textarea markdown con barra minima: grassetto, corsivo, H2, H3, elenco, citazione, link, immagine (upload su
POST /admin/uploads/imagee inserimento disu 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/previewdopo 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-localin 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_TAKENsul campo), elimina; - dopo la creazione si passa a
/admin/blog/:id(replaceUrl); link "Vedi sul sito" quando è pubblicato e la data è passata.
- textarea markdown con barra minima: grassetto, corsivo, H2, H3, elenco, citazione, link, immagine (upload su
- 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 conmedusaSynced: 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-calcaresul 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→ bozza2026-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 conredirect; 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
companycon 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/apialla 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 (processitsx 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), maiprocess.envsparso nel codice. Si usaapp.config.X. Ogni nuova variabile va aggiunta allo schema e aapps/api/.env.example. In produzione sono rifiutati ancheAUTH_COOKIE_SECURE=false, origini CORS non https edEMAIL_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 usabase.partial()(vediadmin/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 dichiaraschema: { body/querystring/params, response: { 200: ... } }con schemi zod. Le route si scrivono comeFastifyPluginAsyncZodper 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 applicativithrow new AppError(409, "EMAIL_TAKEN", "Email già registrata"); con un quarto argomento facoltativodetails: [{ path, message }](es. validazioni calcolate dal codice, come le risposte del configuratore). Validazione → 400VALIDATION_ERRORcondetails: [{ path: "body.email", message }]. Route inesistente → 404NOT_FOUND. Rate limit → 429RATE_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_DIRcon rotazionepino-roll(api.YYYY-MM-DD.N.log, giornaliera o a 50 MB, ultimi 14 file); test → spento. Redattiauthorization,cookie,x-internal-secret,x-signature,set-cookie. Request id letto dax-request-id(Caddy) o generato. Nel codice si logga conrequest.log/app.log, maiconsole. - Sicurezza: helmet (CORP
same-site, perché i frontend sono su sottodomini dello stesso dominio); CORS solo suCORS_ORIGINSconcredentials: true;@fastify/cookieconCOOKIE_SECRET(cookie firmati); rate limit globale per IPRATE_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 produzioneloopback,uniquelocal, cioè Caddy e server SSR sulla rete Docker (accetta anchetrue/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(defaulthttp://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(defaultuploads; in produzione percorso assoluto),UPLOAD_MAX_BYTES(25 MB),PUBLIC_UPLOADS_URL(defaulthttp://localhost:3000/uploads; in produzione l'URL di Caddy, non localhost),EMAIL_PROVIDER(console|brevo|resend, defaultconsole),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:
- Registrazione: ruolo
customer;companyseaccountType: "company"oppure se arrivano ragione sociale + P.IVA (conaccountType: "company"sono obbligatorie);installer(ragione sociale obbligatoria) crea ancheinstaller_profilesinpending.adminnon è mai assegnabile: non è tra i valori diaccountType, e un eventuale camporoleviene scartato. Salva il consensoprivacy(piùmarketingse richiesto) inconsentsconPRIVACY_POLICY_VERSION, poi invia l'email di verifica (linkPRIVATE_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.emailVerifieddice al frontend se mostrare il banner). - 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).
- Access token: JWT HS256 (
JWT_SECRET), durataACCESS_TOKEN_TTL_SECONDS(900 s). Payload solo{ sub: userId, role }. Il frontend lo tiene in memoria e lo invia comeAuthorization: Bearer …. Ruolo o disabilitazione cambiati in DB valgono al refresh successivo (≤ 15 min). - 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), scadenzaREFRESH_TOKEN_TTL_DAYS(30). In DB solo SHA-256, confamily_id, user agent e IP. Insieme viene impostato il cookie indizioiti_session=1(non httpOnly,Path=/, stessi dominio e scadenza, cancellato coniti_rt): dice ai frontend se vale la pena chiamare/auth/refresh(vediapps/public, "Sessione nell'app pubblica"). - Rotazione: ogni
/auth/refreshrevoca 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. - Logout: revoca la family del cookie (solo quel dispositivo) e cancella il cookie; 204 anche senza cookie.
- 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. - Verifica email: token monouso 48 h →
email_verified_at, poi claim degli ordini guest Medusa (vedi "Client Medusa").
- Decorator:
app.authenticate(401UNAUTHORIZEDcon messaggio in italiano) eapp.requireRole(...ruoli)(autentica se serve, poi 403FORBIDDEN). 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_MAXal minuto (default 5) su login, reset-password e verify-email;AUTH_EMAIL_RATE_MAXogni 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,cookieeset-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 utenteiti_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.tsapplica le migrazioni;truncateAll()svuota le tabelle prima di ogni test; i file di test girano in sequenza. L'URL èTEST_DATABASE_URLoppureDATABASE_URL+_test, e il codice rifiuta DB il cui nome non finisce con_test. Helper intest/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=MedusaClientcon 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? })→ headerx-publishable-api-key(per il proxy shop, prompt 13);app.medusa.internal→ headerx-internal-secret; metodi tipizzatiupsertCustomer,setInstallerGroup,claimOrders+requestgenerico per le altre route/internal/*.
- Errori uniformi:
MedusaError { kind, status?, type? }conkind=unavailable(rete, timeout, 5xx: si può ritentare),rejected(4xx,typeMedusa es.not_found),invalid_response(2xx con formato inatteso). Se una route non lo gestisce, l'error handler risponde 503SHOP_UNAVAILABLE(unavailable) o 502BAD_GATEWAY, con messaggio generico; il dettaglio va solo nei log. - Funzioni di sincronizzazione (
medusa-sync.ts, prendonoappcome{ db, medusa, log }):ensureMedusaCustomer(app, user)→ id del customer Medusa: seusers.medusa_customer_idmanca chiamaPOST /internal/customers(upsert per email normalizzata, idempotente) e lo salva (update solo se ancora null). LanciaMedusaError: 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 ruoloinstaller+ profiloapproved+ non disabilitato, altrimentiremove. LanciaMedusaError(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, oppureclaimGuestOrdersse 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.
- registrazione →
- Test (
test/medusa.test.ts, 13 casi) con Medusa mockato (test/medusa-mock.ts: route/internal/*in memoria,down= errore di rete,failStatus).buildTestAppinietta 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=truecreato e id salvato inusers→ 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 contoCents), 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. IlclientSecretserve a Stripe.js. - Autenticazione facoltativa (
app.optionalAuthenticate): senzaAuthorizationla 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 autenticatoviewerForchiamaensureMedusaCustomer(con Medusa giù → 503). - Prezzi installatore: valgono per ruolo
installercon profiloapprovede account non disabilitato. In quel caso i prezzi del catalogo vengono daPOST /internal/pricessul 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/Domaincome 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 cuicustomer_idcompare inusers.medusa_customer_id. La Store API non esponehas_account, quindi la fonte è la mappatura inusers.- 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/loginleggecart_ide 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 cancellacart_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 inmetadata.invoicedel carrello Medusa, checompleteCartWorkflowcopia nell'ordine (metadata: cart.metadata, verificato nel sorgente di core-flows 2.21).nullo 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(originecheckout) 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):
POST /store/payment-collections { cart_id }→ crea la collection, o restituisce quella esistente.POST /store/payment-collections/:id/payment-sessions { provider_id }→ ricrea la sessione con l'importo aggiornato (da richiamare se il carrello cambia). Ilclient_secretè inpayment_sessions[].data.client_secret.- Il frontend conferma il pagamento con Stripe.js.
POST /store/carts/:id/complete→{ type: "order", order }oppure{ type: "cart", error }. Concapture: trueStripe cattura all'autorizzazione.
- Errori Medusa: i 4xx diventano errori in italiano, senza dettagli interni: 404 →
NOT_FOUND, giacenza insufficiente → 409OUT_OF_STOCK, altro → 400SHOP_INVALID_REQUEST. Il messaggio originale va solo nel log (info). 5xx e timeout → 503SHOP_UNAVAILABLE. - Test:
test/shop.test.ts(15 casi, compresi consenso obbligatorio, fattura nei metadata e consensi senza duplicati) con la Store API finta ditest/medusa-mock.ts. Test manuale completo, Stripe compreso, inapps/api/http/shop.http(HTTP Client WebStorm, ambientedev; segreti inhttp-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 →465000conoriginalPrice 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 eseguitoshop.http.
- catalogo: 5.490 € →
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). ImplementazioneLocalDiskStoragesottoUPLOAD_DIR; per passare a S3/MinIO basta un'altra implementazione, passata inbuildApp(config, { storage }). Le route non conoscono il disco.public/: immagini del blog e foto. In produzione la serve Caddy suPUBLIC_UPLOADS_URL(Caddy monta solo la sottocartellapublic/del volume, consubpath). Fuori dalla produzione la serve l'API su/uploads/(@fastify/static, radiceUPLOAD_DIR/public, niente dotfile né indici).private/: documenti. Non è mai servita staticamente, solo in stream daGET /documents/:id/downloaddopo il controllo dei permessi. In DBdocuments.file_pathè la chiave relativa aprivate/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. AltrimentiInvalidStorageKeyError. - Scrittura con flag
wx, che non sovrascrive mai un file esistente.
- Path traversal, doppio controllo: formato della chiave (
-
Upload (
@fastify/multipart, 1 file per richiesta,UPLOAD_MAX_BYTES, max 10 campi da 10 KB), helperreceiveUpload:- estensione non ammessa → 415
UNSUPPORTED_FILE_TYPE, senza scrivere nulla; - scrittura in streaming;
- oltre il limite → 413
FILE_TOO_LARGE; - 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; - nessun file → 400
FILE_REQUIRED; più file → 400TOO_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/.gifimage/jpeg…firma binaria PDF .pdfapplication/pdf%PDF-ZIP .zipapplication/zipPK\x03\x04/PK\x05\x06DWG .dwgimage/vnd.dwgAC10nnDXF .dxfimage/vnd.dxf0+SECTION(ASCII),999(commento) o firma binariaSVG e HTML non sono mai ammessi (rischio XSS). Le immagini pubbliche accettano solo JPEG, PNG, WebP e GIF.
- estensione non ammessa → 415
-
Visibilità (
visibleLevels):Utente Livelli visibili anonimo o disabilitato publiccustomer,companypublic,customerinstallerapprovatopublic,customer,installerinstallerpending/rejectedcome un cliente admintutti, incluso adminInstallatore 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.tsregistra tutte le route/admin/*dietro un unico hookrequireRole("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 hannoUPLOAD_RATE_LIMIT(30/min). - Test (
test/documents.test.ts, 14 casi, storage in una cartella temporaneaTEST_UPLOAD_DIR; helpermultipartBody):- 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 = publishedepublished_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 adraftconserva 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
slugesplicito. Uno slug esplicito già usato → 409SLUG_TAKEN. - Le categorie seguono le stesse regole, generate dal nome.
- generato dal titolo con
- Ricerca:
search_vector @@ websearch_to_tsquery('italian', unaccent(q)), rankts_rank. La sintassi websearch supporta"frase"e-escluso; una query vuota per tsquery non dà errore e restituisce 0 risultati. Esempio:pompa caloretrova "Pompe di calore: guida" (stempomp/calor);perchetrova "Perché…". - Markdown:
marked(istanza dedicata, GFM) →sanitize-htmlcon whitelist.- Ammessi: titoli, paragrafi, liste, citazioni, codice (classi
language-*), tabelle,aeimg,figure. - Schemi ammessi: link
http,https,mailto,tel; immagini solohttp/https(nientedata:), niente URL protocol-relative. - Rimossi:
script,iframe,style, attribution*,styleinline,javascript:. - I link esterni ricevono
rel="noopener noreferrer" target="_blank", le immaginiloading="lazy". - L'HTML si genera a ogni lettura (nessuna cache) e
content_mdresta il sorgente.
- Ammessi: titoli, paragrafi, liste, citazioni, codice (classi
- Copertina:
coverPathè ilpathrestituito daPOST /admin/uploads/image(deve iniziare conimages/); nel DTO diventacoverUrl=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.tswizard: step, domande ( single,multi,numbercon min/max/step/unità,province), opzioni, testi, condizionivisibleIf(branching)testi ed etichette liberamente; cambiando id o valori delle opzioni vanno aggiornati engine.tse i test. AggiornareDEFINITION_VERSIONrules.tstutti 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_VERSIONe riallineare i casi tabellari intest/configurator.test.ts, che sono valori calcolati a mano e falliscono di proposito se le regole cambianoengine.tsfunzioni pure (niente DB né rete): validateAnswers(definition, answers),evaluate(answers)→evaluateWater/evaluatePv/evaluateHeatPump/evaluateBusinesslogica 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 condizionivisibleIfsi valutano in ordine, solo su domande precedenti (un test lo verifica). Errori conpath: "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_ERRORcondetails.
-
Output (
ConfiguratorResult):solution { code, title, description },size { value, unit, label },products[{ handle Medusa, quantity, note }](daPV_PRODUCTS,SOFTENER_SIZES,HEAT_PUMP_SIZESin 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,provincedalle risposte,user_idse autenticato e attivo; - salva i consensi (
source: "configurator"); - invia
quote-request-adminagli admin (ADMIN_NOTIFY_EMAILSo utenti admin, helperservices/notifications.ts) con linkPRIVATE_APP_URL/admin/richieste/<id>(rotta del pannello da confermare nel prompt 32) equote-request-receivedall'utente.
- consenso privacy mancante o
- 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_iddell'utente corrente. Risorsa altrui, in bozza o inesistente → 404 con lo stesso messaggio, così non si rivela che esiste.requireActiveUserrilegge l'utente dal DB (disabilitato → 401). - Numerazione
${anno}-${seq a 4 cifre}(anno nel fuso Europe/Rome):nextQuoteNumber(tx, anno)prendepg_advisory_xact_lock(19001, anno)nella transazione di creazione, poimax(seq)+1. Le creazioni concorrenti dello stesso anno si serializzano fino al commit, senza retry; il vincolo uniqueyear+seqresta la garanzia finale. Testato con 15 creazioni in parallelo. - Destinatario del preventivo (
quotes.user_idè obbligatorio), nell'ordine:userIdesplicito;- l'utente collegato alla richiesta;
- 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 preventivosentconvalid_untilpassato viene mostrato comeexpired. In DB diventaexpiredquando il cliente prova a rispondere. Non c'è un job periodico. - PDF del preventivo: è un documento con visibilità
admine categoriapreventivi, quindi non compare a clienti e installatori in/documents. Il cliente lo scarica solo da/me/quotes/:id/pdf. Una visibilitàcustomerlo renderebbe scaricabile da tutti i clienti. Nel DTO pubblico c'èhasPdf(erapdfDocumentId). - Richieste anonime → account: alla verifica dell'email, al reset password che la verifica e a ogni login con email verificata,
linkAnonymousQuoteRequestscollega lequote_requestsconuser_idnull 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(templatestaff-notification, destinatari comeADMIN_NOTIFY_EMAILS) per nuovo ticket, risposta del cliente, preventivo accettato o rifiutato. I link puntano aPRIVATE_APP_URL/admin/{preventivi,ticket}/<id>(rotte del pannello da confermare nel prompt 32). - Test (
test/quotes.test.ts12,test/tickets.test.ts4):- 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):- in transazione:
statuseapproved_at(azzerato al rifiuto); all'approvazione il ruolo diventainstaller(un admin non può esserlo); syncInstallerGroup: aggiunge o toglie il customer Medusa dal gruppoinstallatori, cioè dal listino riservato;- email
installer-approved/installer-rejected(conreason).
- Se Medusa non risponde, la decisione resta valida e la risposta ha
medusaSynced: false.
- in transazione:
- Recupero della sincronizzazione: a ogni login di un installatore parte in background
syncInstallerGroup(idempotente), oltre aPOST /admin/installers/:id/sync. - Effetto immediato sull'accesso: documenti
installer(visibleLevels) e prezzi riservati (ShopService.viewerFor) leggonoinstaller_profiles.statusdal 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 perrequireRole(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.tsnel prompt 31 e riesportati dageo.ts: il selettore su mappa dell'area riservata usa gli stessi confini), altrimenti 400. In DB ègeography(Point,4326). - Ricerca (
findNearbyInstallers):- condizioni: profilo
approved, ruoloinstaller, account non disabilitato, sede impostata eST_DWithin(location, punto, service_radius_km * 1000); - ordinamento per
ST_Distance(metri su geography →distanceKmcon 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_DWithincon il raggio massimo.
- condizioni: profilo
PROVINCE_CENTROIDS(geo.ts): coordinate del capoluogo per ognuna delle 107 sigle diPROVINCES(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.
- 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
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, campiformGuardFields), usato da contatti e showroom:- honeypot
website: il frontend lo rende invisibile (nontype="hidden", ma fuori schermo contabindex="-1"eautocomplete="off"); se è compilato la richiesta viene scartata in silenzio, con la stessa risposta di successo; - tempo minimo di compilazione:
formToken=<istante emissione>.<HMAC-SHA256>(chiaveCOOKIE_SECRET, prefisso di dominioform-token:), quindi il client non può falsificare l'istante. Inviato prima diFORM_MIN_SECONDS(3 s) → scartato in silenzio; firma non valida, formato errato o più vecchio di 24 h → 400FORM_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).
- honeypot
- Consensi (
services/consents.ts): ogni form con dati personali haacceptPrivacy: z.literal(true)(mancante ofalse→ 400VALIDATION_ERROR, niente salvato) eacceptMarketingfacoltativo, separato.recordConsentssalva, nella stessa transazione del form,privacyed eventualmentemarketingconPRIVACY_POLICY_VERSION, origine (register,contact,showroom,configurator,account), IP, timestamp,user_idse autenticato ed email. Le righe non si cancellano: la revoca valorizzarevoked_at.- I consensi dell'utente sono quelli col suo
user_ide, 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.
- I consensi dell'utente sono quelli col suo
- Contatti: salva
contact_requests(email normalizzata,user_idse autenticato) e avvisa lo staff (notifyStaff, linkPRIVATE_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): fusoEurope/Rome, visite da 60 min, capienza 6 persone per slot (somma delle prenotazionirequested/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 nelleclosedDates(ferie, chiusure straordinarie). Valori provvisori: cambiarli richiede solo di modificareconfig.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.requestedAtdeve coincidere esattamente con l'inizio di uno slot prenotabile, altrimenti 400INVALID_SLOT.- Capienza in concorrenza:
reserveSeatsprendepg_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 → 409SLOT_FULL(testato con 5 richieste in parallelo). - La prenotazione nasce
confirmed(la disponibilità è verificata subito): emailshowroom-booking-confirmedal cliente enotifyStaff(linkPRIVATE_APP_URL/admin/showroom/<id>). Lo statorequestedresta disponibile per l'admin. - Admin, cambio stato:
cancelled/completedliberano i posti; riportare una prenotazione in uno stato attivo ricontrolla la capienza (409 se lo slot si è riempito); il passaggio aconfirmedda un altro stato rimanda l'email di conferma.
- Cancellazione account (
POST /me/delete-account): richiede la password (401INVALID_CREDENTIALSse errata; non basta un access token rubato). Poi:disabled_atvalorizzato (login, refresh e route autenticate rifiutati), consenso marketing revocato, tutte le sessioni revocate,notifyStaffcon utente, ruolo, id customer Medusa e motivo (linkPRIVATE_APP_URL/admin/utenti/<id>). Un admin non può cancellarsi da solo (409ADMIN_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.ts10,test/showroom.test.ts10):- 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/contactscartato come troppo rapido.
Email transazionali (src/services/email.ts, template in src/emails/):
-
app.email.send({ to, template, data }):dataè tipizzato per template (EmailTemplatesinsrc/emails/index.ts).- Il render avviene subito: un errore di template è un bug, quindi
sendlancia. - 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 usaMemoryEmailService, che renderizza con gli stessi template e conserva i messaggi (sent,last(tag)).
- Il render avviene subito: un errore di template è un bug, quindi
-
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. ConEMAIL_OUTBOX_DIRsalva.txte.htmlcompleti. In produzione all'avvio scrive un warning, perché le email non vengono inviate.brevo:POST https://api.brevo.com/v3/smtp/email, headerapi-key=BREVO_API_KEY; tag = nome del template.resend:POST https://api.resend.com/emails,Authorization: Bearer RESEND_API_KEY; tagtemplate=<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-emailregistrazione / reinvio firstName, linkreset-passwordpassword dimenticata firstName, linkregister-existingregistrazione con email già usata (anti-enumerazione) firstName, forgotPasswordUrlorder-confirmationwebhook order.placedorder: OrderEmailData, ordersUrl(il link si omette per i guest)order-placed-adminwebhook order.placed, notifica internaorderorder-canceledwebhook order.canceledorderorder-shippedwebhook order.fulfillment_createdorder, tracking[{ number, url }]quote-request-adminnuova richiesta di preventivo, interna (configuratore) name, email, phone, province, summary[{ label, value }], adminUrlquote-request-receivedconferma all'utente della richiesta dal configuratore name, solutionTitle, summary, disclaimerquote-sentpreventivo inviato (prompt 19) firstName, quoteNumber, title, amountCents, validUntil, linkticket-replyrisposta a un ticket (prompt 19) firstName, ticketSubject, message, linkshowroom-booking-confirmedprenotazione showroom (alla creazione e alla conferma da admin) name, requestedAt (ISO), people, notesinstaller-approvedprofilo installatore approvato firstName, businessName, linkinstaller-rejectedprofilo installatore rifiutato firstName, businessName, reasonstaff-notificationnotifica interna generica (nuovo ticket, risposta del cliente, preventivo accettato/rifiutato, contatto, visita showroom, cancellazione account); helper notifyStafftitle, intro, rows[{ label, value }], urlOrderEmailDataè indipendente da Medusa: importi in centesimi e righe dell'indirizzo già composte. Si costruisce contoOrderEmailData(order)inmedusa-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 daoneLine(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, fusoEurope/Rome.
- layout comune a tabelle con stili inline: intestazione brand (
-
Aggiungere un template: funzione di render in
src/emails/*.ts, poi voce inEmailTemplatese inrenderers(index.ts), esempio nei test (test/emails.test.ts, che fallisce se ne manca uno) e inpreview.ts. -
Anteprima:
npm run email:preview -w apps/api [-- destinatario]invia tutti i template con dati realistici tramite il provider configurato. Conconsole+EMAIL_OUTBOX_DIRsi aprono gli.htmlnel 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.
- rendering di ogni template con dati ostili (
-
Verificato:
npm run email:previewconconsole→ 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/jsonriceve il body grezzo (parseAs: "buffer") e verificax-signature, HMAC-SHA256 esadecimale conWEBHOOK_SECRET, confrontando i digest contimingSafeEqual. Firma assente, malformata o errata → 401INVALID_SIGNATURE, e il body non viene nemmeno interpretato. Poi il payload passa la validazione zod (iduuid,event,occurred_at,data), altrimenti 400. - Escluso dal rate limit globale e dal CORS:
config: { rateLimit: false, cors: false }. - Idempotenza (
handleMedusaWebhook):- insert in
webhook_eventscon l'id dell'evento (on conflict do nothing); - in una transazione, lock della riga con
SELECT … FOR UPDATE: le consegne concorrenti dello stesso evento si mettono in coda; - se
processed_atè già valorizzato → 200duplicate; altrimenti dispatch eprocessed_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.errore risposta 500WEBHOOK_PROCESSING_FAILED. L'evento resta non elaborato, quindi il retry di Medusa lo rielabora; al successoerrortornanull. - Limite: se un evento invia più email e fallisce a metà, il retry rimanda anche quelle già partite.
- insert in
- 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-confirmational cliente (righe, totali con IVA, indirizzo, linkPRIVATE_APP_URL/ordini) +order-placed-adminaADMIN_NOTIFY_EMAILS(csv), oppure, se vuota, a tutti gli utentiadminnon disabilitati.order.canceled→order-canceled(nota sul rimborso).order.fulfillment_created→order-shipped, con il tracking dellelabelsdi quel fulfillment se presenti; nessuna email seno_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/fulfillmentsnon accettalabels; il tracking si aggiunge con la spedizione (/fulfillments/:id/shipments, eventoorder.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: inoltrareshipment_createde 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_eventselaborati senza errori; emailorder-confirmation,order-placed-admin,order-canceledeorder-shippedgenerate (adapter console, file in.outbox).
Database (Drizzle ORM su iti_app):
-
drizzle-orm0.45 + driverpostgres(postgres.js),drizzle-kit0.31 (versioni stabili, non le 1.0 rc). Schema insrc/db/schema/*.ts(riesportato daindex.ts), client insrc/db/client.ts(createDb(url)→{ db, close }), pluginsrc/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 parzialidbEnvSchema/seedEnvSchemadisrc/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:migrateusa il migrator programmatico (nondrizzle-kit migrate), che risolve la cartella dalla root del package: funziona anche dalla build (node dist/db/migrate.js), purchésrc/db/migrationssia presente nell'immagine.drizzle.config.tshaextensionsFilters: ["postgis"](ignoraspatial_ref_sys). Le estensioni (postgis,vector,pg_trgm,unaccent) le crea l'init del container, non le migrazioni (l'utenteiti_appnon è superuser). -
Tabelle di identità (migrazione
0000_init_users):users:iduuid (gen_random_uuid()),email(sempre minuscola: checkusers_email_lowercase+ indice uniqueusers_email_lower_keysulower(email)),password_hash(argon2id),roleenumuser_role(customer|company|installer|admin, defaultcustomer; i valori vengono daUserRoledi@iti/shared),first_name,last_name,company_name,vat_number,medusa_customer_idunique,email_verified_at,created_at,updated_at(aggiornato da Drizzle con$onUpdate),disabled_at.refresh_tokens:id,user_idFK cascade,token_hashunique,family_iduuid (indice),expires_at,revoked_at,created_at,user_agent,ip(inet).password_reset_tokens,email_verification_tokens:id,user_idFK cascade,token_hashunique,expires_at,used_at,created_at.- Tipi esportati dallo schema:
User,NewUser,RefreshToken,NewRefreshToken,PasswordResetToken,EmailVerificationToken.
-
Segreti mai in chiaro:
- token opachi:
generateToken()insrc/services/tokens.ts→{ token, hash }, 256 bit casuali in base64url; in DB solohash(SHA-256 hex, ricerca diretta pertoken_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).
- token opachi:
-
Seed: crea l'admin con
email_verified_atvalorizzato; se l'email esiste non tocca nulla (avvisa se il ruolo non è admin). Admin di dev:admin@iti.local, password inapps/api/.env(SEED_ADMIN_PASSWORD). -
npm audit: 4 avvisi "moderate" suesbuildinterno adrizzle-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:migratesu DB vuoto di prova → 4 tabelle, rieseguito senza errori; applicato aiti_app.db:seed→ creato, poi "già presente". Insert di email maiuscola → violazione del check; email duplicata → violazione dell'indice unique.db:generatedopo 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_DIRin produzione,/health, 404, validazione, AppError, 500 senza stack e generico in produzione, CORS, header helmet, 429).npm run dev -w apps/api→/health200. Avvio senza.env→ elenco delle variabili mancanti ed exit 1. Build avviata conNODE_ENV=production→ log su fileapi.2026-10-02.1.loge 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;edgecon Caddy, API, sito e Medusa (che escono verso Stripe e il provider email). Nessuna porta pubblicata tranne Caddy. - Log: driver
json-filecon rotazione (10 MB × 5) per tutti; l'API scrive anche su file nel volumeapi-logs. L'healthcheck/healthdell'API halogLevel: "warn", così non riempie i log. .dockerignorein root e inmedusa/(nientenode_modules,dist,.env, uploads)..gitattributesforza 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 volumecaddy-data: non cancellarlo, i rate limit di Let's Encrypt sono bassi. - Dentro un sito Caddy riordina le direttive per tipo (es.
handleprima direspond): dove l'ordine conta (dashboard Medusa) si usaroute. - uploads/private non raggiungibile: Caddy monta il volume
uploadsconsubpath: publicin sola lettura, quindiprivate/non esiste nemmeno nel container. Conseguenza: la cartellauploads/publicnon va mai cancellata e ricreata, altrimenti il mount resta sulla vecchia e i file escono 404 fino al riavvio di Caddy.restore.shsvuota il contenuto ma conserva le cartelle di primo livello. - Basic auth e dashboard Medusa: il dashboard (
auth.type: "session") dopo il login chiamaPOST /auth/sessionconAuthorization: Bearer <token>, e il browser non può inviare insieme le credenziali basic (stesso header). Per questo le richieste/auth/*conAuthorization: Bearersono 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/session200 →/admin/users/me200; 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).
- sito: la manda il server SSR con un nonce per risposta (
- Siti aggiuntivi:
import /etc/caddy/sites/*.caddy(cartellainfra/caddy/sites/, montata in sola lettura). Esempion8n.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):
- DNS come sopra; clonare il repo;
cp .env.prod.example .enve compilare. Hash della basic auth:docker run --rm caddy:2-alpine caddy hash-password --plaintext '…', da scrivere tra apici singoli nel.env(contiene$). npm run deploy:build.docker compose -f docker-compose.prod.yml up -d --wait postgres redis medusa.npm run deploy:seed:medusa(conSTRIPE_API_KEYimpostata:seed-basesi ferma senza provider Stripe) → copiare nel.envla rigaMEDUSA_PUBLISHABLE_KEY=pk_…(è dentro un log JSON). Senza chiave l'API non parte (configurazione non valida).npm run deploy:up→ tutti i servizi healthy, certificati emessi.npm run db:seed:prod(admin dell'area riservata daSEED_ADMIN_*, che dopo si possono svuotare) edocker compose -f docker-compose.prod.yml exec medusa medusa user -e <email> -p <password>(utente del dashboard).- Stripe: webhook su
https://api.<dominio>/hooks/payment/stripe_stripe(eventi nella sezione Medusa),whsec_…inSTRIPE_WEBHOOK_SECRET, poinpm run deploy:up.SITE_INDEXABLE=truesolo 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>/conapp.dumpemedusa.dump(pg_dump -Fcda superuser, proprietari inclusi),uploads.tar.gz(public e private),medusa-files.tar.gz,SHA256SUMS. Scrive in.tmp-…e rinomina solo a backup completo; file conumask 077. Tiene gli ultimiBACKUP_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_DIRnel 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 databaseDROP DATABASE … WITH (FORCE),CREATE DATABASEcol proprietario,pg_restore --exit-on-errorda 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
postgresavviato), copiare la cartella del backup inBACKUP_DIR, poi la procedura sopra. Il.envdeve avere gli stessi segreti dell'originale,MEDUSA_PUBLISHABLE_KEYcompresa (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 --waitcon tutti i servizi healthy e solo Caddy con porte; api.…/health200;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.jsoncorretto eno-store; asset con hashimmutable, compressione gzip/zstd;- login sull'API con cookie
Domain=iti.localhost; Securee CORS dell'area; immagine caricata dall'admin servita damedia(200); documento privato: damedia404 anche col percorso esatto, con/private/…,../e%2e%2e; via API 401 senza token e 200 con; il container Caddy non vedeprivate/; - dashboard Medusa: 401 senza credenziali o con password errata, 200 con basic auth, Store API e
/health404, login completo come sopra; whitelist con IP non ammesso → 403;api.…/store/products404; 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 (proprietariiti_app, PostGIS presente, login admin, prodotti, immagine servita). Il primo ripristino ha mostrato il problema delsubpath(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 sezioneapps/apisotto) - 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_IPSda restringere all'ufficio o alla VPN. iti_app_testcreato 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 |