# ITI TECHNOLOGY — Piattaforma web ## Panoramica Portale web per ITI TECHNOLOGY: trattamento acqua, fotovoltaico/energia rinnovabile, comfort (pompe di calore, climatizzazione), mobilità elettrica, soluzioni industriali. Include e-commerce, blog/SEO, configuratore guidato, assistente AI (fase 2), area clienti, area installatori riservata, pannello admin. Due applicazioni separate, un solo backend custom, un motore e-commerce dedicato. ## Architettura ``` iti-technology/ ├── apps/ │ ├── public/ → Angular SSR — sito pubblico (home, soluzioni, blog, shop, configuratore, contatti) │ ├── private/ → Angular SPA — area clienti / area installatori / admin │ └── api/ → Fastify — backend custom, unico gateway verso Medusa ├── packages/ │ ├── shared/ → @iti/shared — tipi e DTO condivisi (TypeScript) │ └── styles/ → SCSS condiviso da public e private (token, mixin, base): solo file, nessun package.json ├── medusa/ → progetto standalone (create-medusa-app) — motore e-commerce; backend in medusa/apps/backend ├── prompts/ → prompt di implementazione (vedi "Stato implementazione") ├── infra/postgres/ → Dockerfile Postgres (PostGIS + pgvector) + script init ├── infra/caddy/ → Dockerfile (Caddy + build di apps/private), Caddyfile, sites/ per siti aggiuntivi ├── infra/backup/ → backup.sh / restore.sh (girano nei container "backup" e "restore") ├── docker-compose.dev.yml → infrastruttura locale (Postgres, Redis) ├── docker-compose.prod.yml → produzione (vedi "Deploy") ├── .env.prod.example → variabili di produzione (sulla VPS diventa `.env`) ├── tsconfig.base.json ├── SECURITY_REVIEW.md → review di sicurezza pre go-live (prompt 34): esito di ogni voce e punti aperti └── CLAUDE.md ``` ### Workspace npm - Root `package.json` (`iti-technology`, private) con `workspaces: ["apps/*", "packages/*"]`. **`medusa/` è escluso**: ha il suo `package.json` e `node_modules`, si installa a parte. - Node 24 LTS (`.nvmrc`, `engines.node >= 24`), npm 11. TypeScript **`~6.0`** come devDependency di root (condiviso dai workspace). Portato da 5.9 a 6.0 nel prompt 22 perché Angular 22 richiede `>=6.0 <6.1`: `apps/api` e `packages/shared` compilano senza modifiche (typecheck, build e 207 test verificati). - `tsconfig.base.json`: strict + `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`, `noImplicitOverride`; target ES2022, `module`/`moduleResolution` `NodeNext`. Ogni package la estende (le app Angular potranno sovrascrivere `module`/`moduleResolution` con `bundler`). - Script root: `npm run build` (tutti i workspace con script `build`), `npm run build:shared`. - Finché un'app non ha un `package.json`, la cartella `apps//` contiene solo `.gitkeep` e npm la ignora. ### `packages/styles` (SCSS condiviso) - `_tokens.scss` (colori, font, spaziature, raggi, ombre, breakpoint), `_mixins.scss` (`mq.up`, `focus-ring`, `visually-hidden`), `_base.scss` (custom properties su `:root`, reset, tipografia, `.container`, `.btn`, form: `.form-grid`, `.form-field`, `fieldset`, `.check`, `.alert`), `_prose.scss` (mixin `prose.container` e `prose.content`: tipografia del corpo degli articoli del blog, usata dalla pagina articolo del sito **e** dall'anteprima dell'editor admin, così l'anteprima è identica al risultato; prompt 32). Spostati qui da `apps/public/src/styles` e `styles.scss` nel prompt 29. - Le due app hanno `stylePreprocessorOptions.includePaths: ["../../packages/styles"]` in `angular.json`: nei componenti basta `@use 'tokens'` / `@use 'mixins' as mq`, e lo `styles.scss` di ogni app fa `@use 'base';` più le sue aggiunte. - Non è un workspace npm (niente `package.json`): è una cartella letta da Sass. Una modifica qui vale per **entrambe** le app. ### `packages/shared` (`@iti/shared`) - ESM (`"type": "module"`), compilato con `tsc` in `dist/` (JS + `.d.ts`), entry `exports["."]` → `dist/index.js`. Import relativi interni con estensione `.js` (richiesto da NodeNext). - Va buildato prima di usarlo dalle app: lo fanno in automatico `postinstall` di root e `prebuild` di `apps/api`; dopo modifiche manuali `npm run build:shared`. - Contiene `UserRole`/`USER_ROLES`, le costanti degli stati di dominio e i DTO pubblici (dettaglio nella sezione "Schema dati custom"). Qui vanno tipi, costanti e DTO condivisi tra `api`, `public` e `private`: niente logica runtime con dipendenze. - Consumo dalle app: dipendenza `"@iti/shared": "*"` nel `package.json` dell'app (npm la risolve via symlink al workspace). ### Infrastruttura di sviluppo (`docker-compose.dev.yml`) Progetto compose `iti-technology-dev`, variabili lette da `.env` in root (copiare `.env.example`; il compose fallisce se mancano). | Comando | Effetto | |---|---| | `npm run infra:up` | build immagine Postgres + avvio, attende i healthcheck (`--wait`) | | `npm run infra:down` | ferma e rimuove i container (i volumi restano) | | `npm run infra:logs` | log in follow | | `docker compose -f docker-compose.dev.yml down -v` | reset completo: cancella i volumi, al prossimo `up` rigira l'init | - **postgres** (porta `POSTGRES_PORT`, default 5432): immagine custom `infra/postgres/Dockerfile` = `postgis/postgis:16-3.4` + `postgresql-16-pgvector` (0.8.x). Volume `postgres-data`. - L'immagine base è su Debian bullseye: il repo PGDG originale risponde 404, quindi il Dockerfile punta a `apt-archive.postgresql.org` (http, pacchetti firmati). Le immagini `postgis/postgis:16-*` sono tutte bullseye; valutare in futuro un'immagine bookworm. - Init `infra/postgres/init/01-init.sql` (solo a volume vuoto): legge le credenziali con `\getenv` e crea i ruoli `APP_DB_USER` / `MEDUSA_DB_USER`, i database `APP_DB_NAME` (`iti_app`), `_test` (`iti_app_test`, per i test automatici di `apps/api`) e `MEDUSA_DB_NAME` (`iti_medusa`) ciascuno di proprietà del proprio utente, `CONNECT` revocato a `PUBLIC` (un utente non accede al DB dell'altro). In `iti_app` e `iti_app_test` crea le estensioni `postgis`, `vector`, `pg_trgm`, `unaccent`. - Healthcheck `pg_isready -h 127.0.0.1`: diventa healthy solo a init completato. - **redis** (`redis:7-alpine`, porta `REDIS_PORT`, default 6379, AOF attivo): volume `redis-data`, healthcheck `redis-cli ping`. URL in `REDIS_URL`. - Variabili: `POSTGRES_USER/PASSWORD/DB/PORT`, `APP_DB_NAME/USER/PASSWORD`, `MEDUSA_DB_NAME/USER/PASSWORD`, `REDIS_PORT`, `REDIS_URL`. ### Medusa (`medusa/`) Medusa **2.21.2**, creato con `create-medusa-app` (senza storefront, npm). Il template 2.21 è un piccolo monorepo **turbo**: root `medusa/package.json` (workspaces `apps/**`) e il backend vero e proprio in **`medusa/apps/backend`** (package `@dtc/backend`). Contiene il proprio `CLAUDE.md`/`AGENTS.md` del template con le convenzioni Medusa: leggerli quando si lavora lì dentro. **Avvio (dev)** — con l'infrastruttura su (`npm run infra:up` in root): ```powershell cd medusa npm install # solo la prima volta (progetto separato dai workspace di root) npm run dev # turbo dev → medusa develop su http://localhost:9000 ``` - Dashboard admin: **http://localhost:9000/app** · health: `GET http://localhost:9000/health` → 200 `OK`. - Comandi CLI da lanciare **in `medusa/apps/backend`**: `npx medusa db:migrate` (migrazioni + indici search), `npx medusa user -e -p ` (crea un utente admin del dashboard). - Utente admin di sviluppo: `admin@iti.local` (password comunicata in chat, non salvata nel repo; se persa ricreare un utente con il comando sopra). **Configurazione** (`medusa/apps/backend/medusa-config.ts`, env in `medusa/apps/backend/.env`, modello in `.env.example`): - `DATABASE_URL` → database `iti_medusa` con utente `iti_medusa` (credenziali `MEDUSA_DB_*` del `.env` di root). - `REDIS_URL` usato da `projectConfig.redisUrl` e dai moduli Redis consigliati dalla guida di deployment: `caching` (provider `caching-redis`), `event-bus-redis`, `workflow-engine-redis`, `locking` (provider `locking-redis`). Tutti sullo stesso Redis. - **In produzione** (`NODE_ENV=production`) la config rifiuta di partire se `JWT_SECRET`, `COOKIE_SECRET`, `INTERNAL_API_SECRET` o `WEBHOOK_SECRET` mancano o hanno meno di 32 caratteri (senza `JWT_SECRET` Medusa userebbe `supersecret`). Avvisa se c'è `STRIPE_API_KEY` senza `STRIPE_WEBHOOK_SECRET`. - CORS da env: `STORE_CORS`, `ADMIN_CORS`, `AUTH_CORS`. Il frontend non chiama Medusa (passa da `apps/api`), quindi `STORE_CORS` resta minimale. - Segreti: `JWT_SECRET`, `COOKIE_SECRET`, `AUTH_MFA_ENCRYPTION_KEY` (generati casuali nel `.env` locale). - File: modulo `@medusajs/medusa/file` con provider `file-local`; cartella `FILE_UPLOAD_DIR` (default `static`, relativa al backend) e URL pubblico `FILE_BACKEND_URL` (default `http://localhost:9000/static`). Medusa serve **sempre e solo** `/static` su `/static`: se in produzione `FILE_UPLOAD_DIR` punta a un volume diverso, quel volume va servito da Caddy e `FILE_BACKEND_URL` aggiornato. **Modifiche al template**: - Rimossi `src/migration-scripts/initial-data-seed.ts` (veniva eseguito da `db:migrate` e creava regione "Europe", USD, spedizioni e prodotti demo) e `src/scripts/seed-demo-products.ts`; rimossi gli script `backend:seed` e `storefront:dev`. Il database `iti_medusa` è stato ricreato pulito. La configurazione di store/regione si fa nel prompt 04. - Tenuto `src/search/` (indice prodotti del modulo search di Medusa, su Postgres); `PRICE_CURRENCIES` in `src/search/helpers/pricing.ts` ridotto a `["eur"]`. - `medusa/package.json` impostato `private: true`; `overrides.lodash = ^4.18.1` (prompt 34: le versioni ≤ 4.17.23 hanno prototype pollution; Medusa verificata con 4.18.1); rimosso `.npmrc` (conteneva un'opzione solo pnpm che faceva emettere warning a npm). **Configurazione vendita Italia — `src/scripts/seed-base.ts`** (idempotente, rieseguibile: cerca ogni entità per nome/handle e crea solo ciò che manca): ```powershell cd medusa/apps/backend npx medusa exec ./src/scripts/seed-base.ts # stampa MEDUSA_PUBLISHABLE_KEY=pk_... ``` Crea/allinea: - store "ITI TECHNOLOGY": unica valuta **EUR, prezzi IVA inclusa** (`is_tax_inclusive`), default su sales channel Web / regione Italia / Magazzino ITI; - regione **"Italia"** (paese `it`, EUR, `automatic_taxes`, IVA inclusa) con payment provider `pp_stripe_stripe`; - tax region `it` (provider `tp_system`) con aliquota di default **IVA 22%** (codice `IVA22`, provvisoria); - sales channel **"Web"** + publishable API key **"Web (apps/api)"** collegata → va in `apps/api/.env` come `MEDUSA_PUBLISHABLE_KEY`; - stock location "Magazzino ITI" (provider fulfillment `manual_manual`), fulfillment set con service zone Italia, shipping option **"Spedizione standard"** flat **15 €** (placeholder, IVA inclusa); - 3 prodotti di esempio pubblicati sul canale Web, una variante "Standard" ciascuno, giacenza 20: addolcitore 1.190 €, kit FV 6 kW 9.900 €, pompa di calore 8 kW 5.490 €. - Medusa crea da sé al primo avvio anche "Default Sales Channel" e "Default Publishable API Key": non usati, lasciati lì. - Importi Medusa v2 in **unità intere** (euro, non centesimi). - Verificato via Store API: la regione espone `pp_stripe_stripe`; carrello con pompa di calore 5.490 € → `item_tax_total` 990 (IVA scorporata 22/122), spedizione 15 € con IVA, `total` 5.505. **Stripe**: - Provider `@medusajs/medusa/payment-stripe`, id `stripe` → provider ID **`pp_stripe_stripe`**. Opzioni: `apiKey` = `STRIPE_API_KEY`, `webhookSecret` = `STRIPE_WEBHOOK_SECRET`, `capture: true` (cattura automatica all'autorizzazione). - Il provider va in errore all'avvio senza `apiKey`, quindi `medusa-config.ts` lo registra **solo se `STRIPE_API_KEY` è impostata** (altrimenti warning); `seed-base.ts` si ferma se il provider manca. - **Webhook da registrare nella dashboard Stripe** (Developers → Webhooks): **`https://api./hooks/payment/stripe_stripe`** (in produzione Caddy inoltra a Medusa solo questo percorso di `api.`), con gli eventi `payment_intent.amount_capturable_updated`, `payment_intent.succeeded`, `payment_intent.payment_failed`, `payment_intent.partially_funded`. Il signing secret (`whsec_...`) va in `STRIPE_WEBHOOK_SECRET`. In locale si può usare `stripe listen --forward-to localhost:9000/hooks/payment/stripe_stripe`. - ⚠️ Nel `.env` locale `STRIPE_API_KEY` è un **segnaposto** (`sk_test_PLACEHOLDER_SOSTITUIRE`): il provider risulta nella regione ma creare una sessione di pagamento fallisce. Inserire la chiave di test reale prima del prompt 26 (checkout). **Listino installatori — `src/scripts/seed-installatori.ts`** (idempotente; richiede `seed-base.ts` già eseguito): ```powershell cd medusa/apps/backend npx medusa exec ./src/scripts/seed-installatori.ts # stampa id gruppo e price list ``` - Customer group **`installatori`**, `metadata = { "code": "installer" }`. Id in dev: `cusgroup_01M3YXVSN2NHXNWAZ2CZQTCKS6`. L'id **cambia tra ambienti**: `apps/api` deve trovarlo per codice (gruppo con `metadata.code = "installer"`, oppure per nome `installatori`) o leggerlo da env, mai hardcodarlo. - Price list **"Listino installatori"**: `type: override`, `status: active`, regola `customer.groups.id = [id gruppo]`. Prezzi EUR IVA inclusa: addolcitore 990, kit FV 8.400, pompa di calore 4.650. Lo script aggiunge solo i prezzi mancanti, non sovrascrive quelli modificati dal dashboard; riallinea stato e regola. - Nota tecnica: il tipo TS di `createPriceListsWorkflow` non espone `type`, ma lo step lo inoltra al Pricing Module (come `POST /admin/price-lists`); senza, il default sarebbe `sale`. - Verificato via Store API (stesso carrello con la pompa di calore): guest → 5.490, cliente normale → 5.490, cliente nel gruppo → **4.650**. **Come aggiungere/rimuovere un customer dal gruppo** (servirà ad `apps/api` all'approvazione di un installatore, prompt 20): - Admin API: `POST /admin/customer-groups/{groupId}/customers` con body `{ "add": ["cus_..."] }` (o `{ "remove": [...] }`), autenticata come utente admin Medusa (`Authorization: Bearer ` da `POST /auth/user/emailpass`, oppure secret API key). - Dentro Medusa (workflow/route custom, prompt 06): `linkCustomersToCustomerGroupWorkflow(container).run({ input: { id: groupId, add: [customerId] } })`. - Il prezzo del gruppo si applica al carrello del customer. Un carrello già associato al customer **prima** dell'approvazione viene riprezzato solo alla successiva modifica delle righe (o a un nuovo `transferCartCustomer`); quelli associati dopo (login, `POST /internal/carts/:id/customer`) sono riprezzati subito. **Route interne `/internal/*`** (chiamabili solo da `apps/api`): - Protezione: middleware `src/utils/internal-secret.ts` registrato in `src/api/middlewares.ts` su `/internal*`. Header **`x-internal-secret`** = **`INTERNAL_API_SECRET`** (stesso valore in `medusa/apps/backend/.env` e `apps/api/.env`). Confronto con `timingSafeEqual` sui digest SHA-256 (nessuna fuga di contenuto o lunghezza). Segreto mancante o errato, oppure env non impostata → **401** `{"type":"unauthorized"}`. - Body validati con zod (`src/api/internal/validators.ts`, `validateAndTransformBody`) → 400 `invalid_data`. Le email vengono normalizzate con **trim + minuscolo**: `apps/api` deve usare la stessa normalizzazione. - Errori Medusa standard: 404 `not_found` per id inesistenti. | Route | Body / query | Implementazione | Risposta | |---|---|---|---| | `POST /internal/customers` | `{ email, first_name, last_name, company_name? }` | workflow custom `upsertAccountCustomerWorkflow` → cerca per email con `has_account=true`, altrimenti **`createCustomersWorkflow`** (con `has_account: true`) | `201 { customer, created: true }` / `200 { customer, created: false }` | | `POST /internal/customers/:id/groups` | `{ action: "add" \| "remove" }` | workflow custom `setInstallerGroupWorkflow` → trova il gruppo per `metadata.code = "installer"`, poi **`linkCustomersToCustomerGroupWorkflow`** | `{ customer_id, group: { id, name }, member }` | | `POST /internal/carts/:id/customer` | `{ customer_id }` | **`transferCartCustomerWorkflow`** (aggiorna customer ed email del carrello e **riprezza** le righe → listino installatori) | `{ cart }` (id, email, customer_id, total, items) | | `POST /internal/carts/:id/guest` | `{ email }` | workflow custom `setCartGuestEmailWorkflow` → customer **guest** (`has_account=false`) per l'email (trovato o creato) + **`transferCartCustomerWorkflow`**; `not_allowed` se il carrello è già di un account | `{ cart_id, email }` | | `POST /internal/prices` | `{ customer_id, region_id, variant_ids[] }` (max 200) | `query.graph` delle varianti con `calculated_price` nel contesto `{ region_id, currency_code, customer.groups }` del customer (come la Store API con customer autenticato) | `{ prices: { [variant_id]: { amount, original_amount, currency_code, price_list_type } } }` | | `POST /internal/orders/claim` | `{ customer_id, email }` | per ogni ordine con quella email (ILIKE letterale, case-insensitive) il cui customer è guest e non annullato: workflow custom `claimGuestOrderWorkflow` = **`requestOrderTransferWorkflow`** + lettura token + **`acceptOrderTransferWorkflow`** | `{ claimed: [order_id], failed: [{ order_id, message }] }` | | `GET /internal/orders/:id` | — | **`getOrderDetailWorkflow`** (niente draft) con righe (anche `thumbnail`), totali, indirizzo di spedizione, metodi di spedizione, fulfillment con `labels` (tracking) | `{ order }` / 404 | | `GET /internal/customers/:id/orders` | `?limit=20&offset=0` (max 100) `[&display_id=N]` | **`getOrdersListWorkflow`** (filtro `customer_id`, niente draft, più recenti prima; con `display_id` solo quell'ordine, se è del customer: usato da `GET /me/orders/:number`) | `{ orders, count, offset, limit }` con `payment_status`, `fulfillment_status`, totali, righe | Workflow custom in `src/workflows/internal/`. Note: - **Email del checkout guest — perché `/internal/carts/:id/guest`**: con la Store API, impostare l'email su un carrello senza customer (`POST /store/carts/:id { email }`, o `email` alla creazione) passa da `findOrCreateCustomerStep`, che cerca per email **senza filtrare `has_account`**. Se esiste un account registrato con quell'email, il carrello viene associato a quell'account con i suoi prezzi di gruppo: chiunque digiti l'email di un installatore otterrebbe il listino riservato, e l'ordine finirebbe nello storico dell'account. Per questo `apps/api` **non passa mai `email` alla Store API** e per i guest usa `/internal/carts/:id/guest`. Un carrello che ha già un customer guest può cambiare email senza problemi: resta guest. - **Listini `type: override`**: nel `calculated_price` Medusa riporta come `original_amount` il prezzo del listino stesso, non quello pubblico. Il prezzo pubblico è il `calculated_price` calcolato senza contesto customer. - **Customer account vs guest**: Medusa ammette un customer per coppia `(email, has_account)`. Il checkout guest crea customer con `has_account=false`; quelli creati da Fastify hanno `has_account=true`. Questo è **necessario**: `requestOrderTransferWorkflow` rifiuta il trasferimento verso customer guest. - **Claim**: il flusso ufficiale prevede che il token arrivi al cliente via email. Qui viene letto e accettato internamente, quindi **`apps/api` deve chiamare il claim solo dopo aver verificato il possesso dell'email** (es. email verificata alla registrazione). Il claim richiede che il customer abbia la stessa email del body. Rieseguirlo non ha effetti (nessun ordine guest residuo). - **Test manuali**: `medusa/apps/backend/http/internal.http` (HTTP Client WebStorm, ambiente `dev`). Le variabili pubbliche stanno in `http-client.env.json`, i segreti in `http-client.private.env.json` (gitignored; modello in `http-client.private.env.example.json`). L'ordine guest di test si crea come draft order via Admin API e poi si converte, così non serve un pagamento. - **Verificato** (stesso scenario via curl): 401 su tutte e 5 le route senza segreto o con segreto errato; customer 201 → 200 con stesso id; carrello guest 5.490 → dopo gruppo e associazione **4.650**; claim dell'ordine guest (email con maiuscole) → `claimed`, ripetuto → vuoto; elenco ordini con l'ordine collegato; customer inesistente 404; body invalido 400. **Webhook Medusa → Fastify** (`src/subscribers/fastify-order-webhooks.ts` + `src/utils/fastify-webhook.ts`): - Eventi (nomi verificati in `OrderWorkflowEvents` di Medusa 2.21): | Evento | Emesso da | `data` originale | |---|---|---| | `order.placed` | `completeCartWorkflow` (checkout), conversione draft order | `{ id }` | | `order.canceled` | `cancelOrderWorkflow` | `{ id }` | | `order.fulfillment_created` | `createOrderFulfillmentWorkflow` | `{ order_id, fulfillment_id, no_notification }` | - Destinazione: `POST {FASTIFY_WEBHOOK_URL}/webhooks/medusa`, `Content-Type: application/json`. - **Payload**: ```json { "id": "2f95bee9-bac2-52d3-848a-52abba09eeb7", "event": "order.placed", "occurred_at": "2026-10-02T18:43:21.765Z", "data": { "id": "order_01...", "order": { "id": "order_01...", "display_id": 2, "status": "pending", "email": "cliente@example.com", "customer_id": "cus_01...", "currency_code": "eur", "total": 1190, "created_at": "..." } } } ``` `data` = dati originali dell'evento + `order` (snapshot letto al momento dell'evento; `null` se non trovato). `occurred_at` = istante in cui il subscriber elabora l'evento. - **`id` evento**: UUID v5 **deterministico** da `nome evento + data originale`. Lo stesso evento ha sempre lo stesso id, anche se il subscriber viene rieseguito, ed è identico in tutti i retry. `apps/api` deduplica su questo id (`webhook_events`, prompt 14). - **Firma**: header **`x-signature`** = HMAC-SHA256 in **esadecimale minuscolo** del **body grezzo** (i byte esatti inviati) con chiave **`WEBHOOK_SECRET`** (stesso valore in `medusa/apps/backend/.env` e `apps/api/.env`). Lato Fastify: calcolare l'HMAC sul raw body **prima** del parse JSON e confrontare con `timingSafeEqual`. Esempio di riferimento in `medusa/apps/backend/http/webhook-receiver.mjs`. - **Retry**: fino a **5 tentativi** se la risposta non è 2xx o c'è un errore di rete (timeout 5 s per tentativo), con backoff esponenziale 2 s, 4 s, 8 s, 16 s (base configurabile con `WEBHOOK_RETRY_BASE_MS`). Ogni tentativo fallito è un `warn` nei log, l'abbandono è un `error` con il payload completo (per reinvio manuale). - **Mai bloccante**: gli eventi arrivano dopo il commit dell'ordine; il subscriber prepara il payload e avvia la consegna **senza await**. Il worker dell'event bus elabora un job alla volta: con l'await i retry di un webhook bloccavano tutti gli altri subscriber, Medusa inclusa (verificato). - **Limite noto**: i retry vivono in memoria. Se Medusa si riavvia durante il backoff, l'invio si perde (resta il log). Se servirà garanzia di consegna: outbox su DB oppure job di riconciliazione. - Senza `FASTIFY_WEBHOOK_URL` o `WEBHOOK_SECRET` l'invio viene saltato con un warning. - **Ricevitore**: `POST /webhooks/medusa` di `apps/api` (prompt 14, vedi sezione `apps/api`). **Receiver di test** standalone, utile per provare il subscriber senza l'API: `node http/webhook-receiver.mjs 3000` in `medusa/apps/backend`. Verifica la firma e stampa le richieste; con `FAIL=n` risponde 503 alle prime n. - **Verificato**: ordine (draft convertito) → `order.placed` firmato; con `FAIL=2` → 503, 503, 200 al 3° tentativo (backoff 2 s, 4 s); `order.fulfillment_created` e `order.canceled` consegnati e firmati. Con l'endpoint giù l'ordine si completa in ~0,6 s e i 5 tentativi di `order.placed` e `order.fulfillment_created` compaiono nei log in parallelo, fino all'abbandono. - ⚠️ In sviluppo, prima di riavviare Medusa assicurarsi che le istanze precedenti di `turbo dev`/`medusa develop` siano chiuse: chiudere solo il processo sulla porta 9000 lascia vivi i worker, che continuano a consumare gli eventi dalla coda Redis condivisa. **Decisione — prezzi IVA inclusa**: i prezzi in EUR per la regione Italia sono inseriti e mostrati **IVA inclusa** (vendita B2C); Medusa scorpora l'IVA nei totali. Vale anche per i listini (es. installatori, prompt 05), salvo diversa decisione. **Decisione — catalogo nell'MVP**: prodotti, categorie, prezzi, listini e ordini si gestiscono dal **dashboard admin integrato di Medusa** (`/app`). Il pannello admin custom (`apps/private`, prompt 32) non duplica la gestione catalogo (copre preventivi, ticket, installatori, utenti, blog, documenti, contatti/showroom) e mette un link diretto al dashboard Medusa. In produzione il dashboard è su `shop-admin.{DOMAIN}`, protetto da whitelist IP **e** basic auth (vedi "Deploy"). **Perché due app**: `public` ha bisogno di SSR per la SEO (blog, pagine soluzioni); `private` è dietro login, SPA è sufficiente, nessun bisogno di SSR. **Perché Medusa resta a parte**: ha il suo scaffolding e versioning proprio, si tratta come servizio esterno consumato via API, non si modifica la sua struttura interna. ## Stack tecnico | Layer | Scelta | |---|---| | Frontend pubblico | Angular (standalone, signals, zoneless, SCSS) + SSR | | Frontend privato | Angular (standalone, signals, zoneless, SCSS) SPA | | Backend custom | Fastify / Node.js | | Database app | PostgreSQL (+ pgvector per RAG futuro) | | Geolocalizzazione | PostGIS (assegnazione installatori per zona) | | E-commerce | Medusa (Node/TS), proprio Postgres separato | | Pagamenti | Stripe (plugin ufficiale Medusa) | | Storage media | Disco VPS, volume Docker + Caddy (no MinIO per ora) | | Reverse proxy / TLS | Caddy, sottodomini sullo stesso dominio | | Email transazionali | Provider esterno (da scegliere: Brevo o Resend) | | Ricerca | `tsvector` Postgres (full-text nativo) | | Hosting | VPS esistente, Docker Compose | | Automazioni | n8n (istanza già esistente sulla VPS) | ## Decisioni architetturali chiave **Autenticazione — login unico** `apps/api` (Fastify) è l'unico gateway di autenticazione. Il frontend non parla mai direttamente con Medusa. 1. Login/registrazione → sempre verso Fastify, JWT proprio 2. Fastify crea prima la riga in `users` (Postgres), poi il customer Medusa corrispondente via Admin API, mappato con `users.medusa_customer_id` 3. Operazioni shop (carrello/checkout) → Fastify fa da proxy verso Medusa usando quella mappatura 4. Un solo login, un solo JWT, zero password duplicate **Sync Medusa → Fastify** Subscriber scritto dentro il progetto Medusa (reagisce a `order.placed`, `order.completed`, ecc.) che chiama `/webhooks/medusa` su Fastify via HTTP. **Checkout** Guest checkout abilitato (solo email, nessun account obbligatorio). Al momento della registrazione, Fastify cerca ordini Medusa passati con la stessa email e li collega al nuovo `user_id`. **Listino riservato installatori** Customer groups + price lists in Medusa — nessuna logica di pricing custom da scrivere, è nativo. **IVA** Aliquota flat 22% provvisoria per non bloccare il checkout. Prezzi inseriti e mostrati IVA inclusa (vedi sezione Medusa). Aliquote agevolate e fatturazione elettronica da affinare dopo il lancio. **Configuratore** MVP: wizard a domande guidate con logica a regole (branching + tabelle di soglie in Fastify), **nessuna AI**. Output predicibile, zero costi/latenza OpenAI, genera comunque una `quote_request`. **Assistente AI — STEP 2, non nell'MVP** Quando verrà implementato: RAG con pgvector. Schede tecniche → chunk → embedding OpenAI → salvati in Postgres. Domanda utente → embedding → similarity search → chunk rilevanti nel prompt → risposta GPT ancorata ai documenti reali (niente invenzioni). Serve sia per l'assistente conversazionale che per arricchire il configuratore in futuro. Attenzione GDPR: richiede DPA con OpenAI (dati utente verso processor extra-UE). **Editor blog** Custom, markdown semplice: textarea + preview live nell'admin, salvato come testo in `blog_posts.content`, convertito in HTML a runtime (`marked`/`remark`) lato Fastify. Niente rich-text/WYSIWYG, niente dipendenze SaaS terze. **Monitoring** Per ora log su file/console. Nessun Sentry o simile per il momento. ## Schema dati custom (Postgres — apps/api) Database `iti_app`, schema Drizzle in `apps/api/src/db/schema/` (`users.ts` identità, `domain.ts` dominio, `columns.ts` tipi custom). Prodotti, ordini e carrelli **non sono in questo schema**: vivono nel Postgres di Medusa e si collegano tramite `users.medusa_customer_id`. **ERD sintetico** (PK = chiave primaria, FK → tabella [azione on delete]; `?` = nullable; tutte le tabelle hanno `created_at`, quasi tutte `updated_at`): ``` users id PK · email (minuscola, unique lower()) · password_hash · role user_role · first_name · last_name · company_name? · vat_number? · medusa_customer_id? unique · email_verified_at? · disabled_at? ├─< refresh_tokens user_id → users [cascade] · token_hash unique · family_id · expires_at · revoked_at? · user_agent? · ip? inet ├─< password_reset_tokens user_id → users [cascade] · token_hash unique · expires_at · used_at? ├─< email_verification_tokens (stessa struttura di password_reset_tokens) ├─1 installer_profiles user_id PK → users [cascade] · business_name · vat_number? · phone? · address? │ · location? geography(Point,4326) [GIST] · service_radius_km (>0, default 50) │ · certifications text[] · status installer_status · approved_at? · notes? (solo admin) ├─< quote_requests id · user_id? → users [set null] (anonime ammesse) · email (minuscola) · name · phone? · province? │ │ · configurator_input jsonb · configurator_output? jsonb · status quote_request_status │ └─< quotes id · quote_request_id? → quote_requests [set null] · user_id → users [restrict] │ · year · seq (unique year+seq) · number unique "2026-0001" · title · status quote_status │ · amount_cents bigint (≥0) · pdf_document_id? → documents [set null] · valid_until? date · sent_at? ├─< tickets id · user_id → users [cascade] · subject · status ticket_status · priority ticket_priority │ └─< ticket_messages id · ticket_id → tickets [cascade] · author_id → users [restrict] · body ├─< blog_posts (author) author_id? → users [set null] ├─< contact_requests id · user_id? → users [set null] · name · email · phone? · subject? · message · status contact_request_status ├─< showroom_bookings id · user_id? → users [set null] · name · email · phone? · requested_at (inizio slot) · people (>0) · notes? · status showroom_booking_status └─< consents id · user_id? → users [cascade] · email? (almeno uno dei due) · type consent_type · version · source? · accepted_at · revoked_at? · ip? inet blog_categories id · slug unique · name · description? └─< blog_posts id · slug unique · title · excerpt? · content_md · cover_path? · status blog_post_status · published_at? · seo_title? · seo_description? · category_id? → blog_categories [set null] · search_vector tsvector GENERATED [GIN] documents id · title · category? · file_path (relativo all'area private/) · mime_type · size_bytes bigint · visibility document_visibility · product_ref? (handle Medusa) webhook_events id uuid PK (= id evento Medusa, idempotenza) · event · payload jsonb · received_at · processed_at? · error? ``` **Enum Postgres** (valori da `@iti/shared`, unica fonte): `user_role` (customer, company, installer, admin) · `installer_status` (pending, approved, rejected) · `quote_request_status` (new, contacted, converted, closed) · `quote_status` (draft, sent, accepted, rejected, expired) · `ticket_status` (open, in_progress, waiting_customer, closed) · `ticket_priority` (low, normal, high, urgent) · `blog_post_status` (draft, published) · `document_visibility` (public, customer, installer, admin) · `contact_request_status` (new, in_progress, closed) · `showroom_booking_status` (requested, confirmed, cancelled, completed) · `consent_type` (privacy, marketing). **Note di schema**: - **Email minuscole** ovunque (check `*_email_lowercase`): il codice deve normalizzare con trim + `toLowerCase()` prima di scrivere e cercare. - **Full-text blog**: `search_vector` = `setweight(to_tsvector('italian', immutable_unaccent(title)),'A') || …(excerpt,'B') || …(content_md,'C')`, colonna generata `STORED` con indice GIN. Query: `search_vector @@ websearch_to_tsquery('italian', unaccent($q))`, ordinamento con `ts_rank`. `immutable_unaccent(text)` è un wrapper IMMUTABLE di `unaccent` (le colonne generate non accettano funzioni STABLE), creato dalla migrazione custom `0001_immutable_unaccent.sql`. - **Geografia**: custom type `geographyPoint` (`columns.ts`). In TypeScript è `{ lat, lng }` (`GeoPoint` di shared): in scrittura diventa `ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography`, in lettura viene convertito da EWKB. Per i calcoli usare la colonna in `sql\`…\``: `ST_DWithin(location, punto, service_radius_km * 1000)` e `ST_Distance` (metri). - **Numero preventivo**: `year` + `seq` (unique) e `number` = `${year}-${seq a 4 cifre}`; la generazione (prompt 19) prende `max(seq)+1` dell'anno e riprova sul conflitto unique. - Migrazione `0003_quotes_sent_at`: `quotes.sent_at` (primo invio al cliente). - ⚠️ **drizzle-kit e `geography`**: drizzle-kit genera `"geography(Point,4326)"` tra virgolette (tipo inesistente). In `0002_domain.sql` è stato corretto a mano; se una futura migrazione tocca la colonna `location`, correggere di nuovo il SQL generato prima di applicarlo. - **Tipi riga** esportati da `apps/api/src/db/schema` (`User`, `InstallerProfile`, `Quote`, `BlogPost`, `NewBlogPost`, …: `$inferSelect` / `$inferInsert`). **`packages/shared`** (`@iti/shared`): - `domain.ts`: costanti degli stati (`INSTALLER_STATUSES`, `QUOTE_STATUSES`, …) e relativi tipi; `user-role.ts`: `USER_ROLES` / `UserRole`. - `dto.ts`: **DTO pubblici** delle risposte API (`UserDto`, `InstallerProfileDto`, `QuoteRequestDto`, `QuoteDto`, `TicketDto`, `TicketMessageDto`, `BlogCategoryDto`, `BlogPostSummaryDto`, `BlogPostDetailDto`, `BlogPostAdminDto`, `DocumentDto`, `ShowroomBookingDto`, `ShowroomBookingAdminDto`, `ShowroomSlotsDto`, `ContactRequestDto`, `FormTokenDto`, `ConsentDto`, `UserAdminDto`, `AdminSummaryDto`, `GeoPoint`, `Paginated`). Campi camelCase, date ISO string, importi in centesimi; mai campi interni (hash, `file_path`, `notes` admin, payload). Le route dei prompt successivi possono rifinirli. - Ora contiene anche codice runtime (le costanti), quindi `apps/api` lo importa da `dist/`: viene compilato da `postinstall` di root (dopo ogni `npm install`) e da `prebuild` di `apps/api`. **Dopo una modifica a `packages/shared` lanciare `npm run build:shared`** (oppure `npx tsc -p packages/shared -w` mentre si sviluppa). ## `apps/public` (Angular SSR) Package `@iti/public`: **Angular 22.2** (ultima stabile al 2026-10), standalone, signals, **zoneless** (default da Angular 21), SCSS, `@angular/ssr` con server Express. Generato con `ng new` (`--ssr --zoneless --style scss`), poi adattato al monorepo. ```powershell npm run content -w apps/public # rigenera l'indice dei contenuti (parte da solo prima di dev/build/test/typecheck) npm run dev -w apps/public # ng serve → http://localhost:4200 (SSR anche in sviluppo, usa src/server.ts) npm run build -w apps/public # build browser + server in apps/public/dist/public npm run start -w apps/public # node dist/public/server/server.mjs → http://localhost:4000 npm run test -w apps/public # ng test (vitest + jsdom) npm run typecheck -w apps/public ``` **Struttura**: ``` apps/public/ ├── public/ → statici: logo.svg, favicon.svg/.ico, og-default.png, images/aree/.svg (tutti segnaposto) ├── scripts/content-index.mjs → genera src/content/generated/ (indice dei file di contenuto) ├── scripts/vendor-assets.mjs → copia CSS e immagini di Leaflet in public/vendor/leaflet (generata, in .gitignore) ├── src/ │ ├── content/ → CONTENUTI del sito (vedi "Contenuti" sotto) │ ├── server.ts → Express: compressione, robots.txt, sitemap.xml, statici, poi rendering Angular │ ├── server/ → runtime-config.ts (env del server), sitemap.ts (funzioni pure) │ ├── environments/environment.ts → default di sviluppo (URL sito/API/area riservata, indexable=false) │ ├── styles.scss → `@use 'base'` (token, reset, tipografia, .btn, form: tutto in packages/styles) │ └── app/ │ ├── app.ts / app.routes.ts / app.routes.server.ts / app.config(.server).ts │ ├── core/ → site.ts (menu, slug validi, sitemap), app-config.ts, api.service.ts, auth.interceptor.ts, session.service.ts, cart.store.ts, seo.service.ts, structured-data.ts, format.ts, listing.ts (URL, ricerca e paginazione degli elenchi), blog.ts, shop.ts, configurator.ts (logica pura del wizard), consent.ts + consent.service.ts (cookie), form-guard.ts (anti-spam), contact.ts, showroom.ts, response-status.ts │ ├── layout/ → header.ts, footer.ts, cookie-banner.ts │ ├── shared/ → page-header, page-placeholder, not-found-view, solution-body, needs-picker, related-products, faq-list, quote-cta, latest-posts, post-card, product-card, list-search, pagination, filter-nav, placeholder-badge, consent-fields, honeypot, osm-map, legal-page │ └── pages/ → un componente per pagina (export default, caricati lazy; la home ha home.html/home.scss) ``` **Convenzioni**: - Componenti standalone con `ChangeDetectionStrategy.OnPush`, template e stili inline per i componenti piccoli, `input()`/`computed()`/`effect()`. I parametri di route arrivano come **input** (`withComponentInputBinding`). Le pagine sono `export default` e si caricano con `loadComponent: () => import('./pages/x')`. - **TypeScript**: `tsconfig.json` estende `tsconfig.base.json` (strict, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`) con `module: preserve`, `moduleResolution: bundler`, lib DOM; `strictTemplates` ed extended diagnostics come errori. - **Stili**: i token SCSS diventano custom properties su `:root` (`--color-primary`, `--space-4`, `--font-size-xl`…) e i componenti usano `var(--…)`. Breakpoint mobile-first con `@use 'mixins' as mq; @include mq.up(md) { … }` (`stylePreprocessorOptions.includePaths: ["../../packages/styles"]`, quindi basta `@use 'tokens'`/`'mixins'`; vedi "`packages/styles`"). Palette: blu acqua `#0b5c7a` (brand, uguale alle email), verde sostenibilità `#1f7a4d`, ambra energia `#f2a900`, un colore per macro-area (`--color-area-`). **Font di sistema**: nessuna richiesta a Google Fonts (GDPR, prestazioni). - Locale `it-IT` registrato (`LOCALE_ID`), ``. **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/.ts` | macro-area: label del menu, `order`, titolo, tagline, description (meta), intro, immagine, vantaggi, "come funziona", `relatedProducts`, FAQ, `configuratorPath` | `/soluzioni/` | | `solutions//.ts` | soluzione: stessi campi più `area` | `/soluzioni//` | | `company/.ts` | pagina azienda: titolo, description, lead, sezioni (paragrafi ed elenchi), CTA facoltativa | `/azienda/` (`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//.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/.ts`) o una nuova **area** (`areas/.ts` + almeno una soluzione; il colore `--color-area-` va aggiunto ai token e l'immagine in `public/images/aree/`). - **Come funziona**: `scripts/content-index.mjs` scansiona le cartelle e scrive `src/content/generated/` (`paths.ts` con i soli slug per route e sitemap; `areas.ts`, `solutions.ts`, `company.ts` con gli import). Il builder Angular (esbuild) non supporta `import.meta.glob`, per questo serve il generatore. Parte da solo in `postinstall`, `predev`, `prebuild`, `pretest` e `pretypecheck`; la cartella è in `.gitignore`. **Con `npm run dev` già avviato**, dopo aver aggiunto un file lanciare `npm run content -w apps/public` (o riavviare il dev server). - **Caricamento**: le aree (`content/areas.ts`) e le pagine azienda entrano nel bundle iniziale, perché servono a header e footer. Le soluzioni (`content/catalog.ts`) si caricano solo con le pagine (chunk lazy). Le route validano gli slug su `generated/paths.ts`, senza importare i contenuti. - **Testi**: le `description` stanno tra 70 e 170 caratteri (lo verifica il test). Le risposte delle FAQ sono testo semplice, perché finiscono anche nel JSON-LD. Le immagini sono percorsi in `public/` con `alt` descrittivo e dimensioni intrinseche (per `NgOptimizedImage`). - **Prodotti correlati**: `relatedProducts` contiene handle Medusa. La card legge `GET /shop/products/:handle` (prezzo IVA inclusa, link a `/shop/`). Un handle inesistente, o l'API spenta, fa sparire la card senza errori. Handle reali oggi: `addolcitore-domestico-20l`, `kit-fotovoltaico-6kw-accumulo`, `pompa-di-calore-aria-acqua-8kw`. - **Validazione** (`src/content/content.spec.ts`): slug uguali ai nomi dei file, almeno una soluzione per area, campi obbligatori, lunghezza delle description, ordini univoci, FAQ → JSON-LD FAQPage valido, esigenze e realizzazioni su aree e percorsi esistenti, sitemap completa. **Parametri di query verso altre pagine** (da leggere nei prompt 27-28): - `/configuratore?percorso=acqua|fotovoltaico|pompa_calore|azienda`: percorso preselezionato, inviato da "Qual è la tua esigenza?" e dalle CTA "Calcola una stima". Il wizard accetta anche `?esigenza=` come alias, con gli stessi valori: il prompt 27 citava `?esigenza=acqua-calcarea`, ma il sito usava già `percorso` con i valori dell'API. - `/contatti?oggetto=preventivo|sopralluogo|installatori|candidatura[&soluzione=][&prodotto=]`: oggetto precompilato del form contatti ("Richiedi un preventivo", "Prenota un sopralluogo", pagine azienda; `prodotto` dalla scheda prodotto dello shop). **Route** (tutte lazy, `app.routes.ts`): | Percorso | Note | |---|---| | `/` | home in 11 sezioni (hero con "Trova la soluzione giusta" e "Richiedi preventivo", settori con Acqua in evidenza, energia rinnovabile, servizi per privati e aziende, "Qual è la tua esigenza?", realizzazioni, partner, recensioni, ultimi articoli dall'API, contatti); JSON-LD Organization + WebSite + LocalBusiness | | `/azienda`, `/azienda/:pagina` | da `content/company/`: chi siamo, certificazioni, partner, lavora con noi, con sotto-menu; `/azienda/chi-siamo` → **301** `/azienda`; slug sconosciuto → 404; JSON-LD BreadcrumbList | | `/soluzioni/:area`, `/soluzioni/:area/:soluzione` | da `content/areas` e `content/solutions`: descrizione, vantaggi, come funziona, (area) elenco soluzioni, prodotti correlati, FAQ, CTA preventivo, (soluzione) altre soluzioni dell'area. JSON-LD **BreadcrumbList + FAQPage**. Valori sconosciuti → 404 (`canMatch`); `/soluzioni` → **301** `/` | | `/realizzazioni` | galleria da `content/projects.ts`, filtro `?area=` fatto di link (funziona anche lato server); canonical sempre `/realizzazioni` | | `/shop`, `/shop/categoria/:categoria`, `/shop/:handle`, `/carrello` | catalogo, scheda prodotto e carrello (vedi "Shop e carrello" sotto); carrello `noindex` | | `/checkout` | checkout in 4 passi con Stripe (vedi "Checkout" sotto), `noindex` | | `/ordine/:numero/confermato` | conferma d'ordine, `noindex` (numero non numerico → 404) | | `/blog`, `/blog/categoria/:categoria`, `/blog/:slug` | elenco con ricerca e paginazione, dettaglio articolo (vedi "Blog" sotto) | | `/configuratore`, `/configuratore/grazie` | wizard a regole e ringraziamento (vedi "Configuratore" sotto); `grazie` è `noindex` | | `/contatti`, `/showroom`, `/privacy`, `/cookie` | vedi "Contatti, showroom e cookie" sotto | | `**` | 404 con **status HTTP 404** (`RESPONSE_INIT`) e `noindex` | - **404 decisi dalla pagina** (dati dall'API, es. articolo inesistente): `injectResponseStatus()` (`core/response-status.ts`) nel costruttore, poi `setStatus(404)` in un `effect` quando arriva la risposta. Funziona perché il server attende le richieste HTTP in corso prima di inviare la risposta. Contenuto con `` e `setPage({ …, noindex: true })`. Con l'API giù le pagine rispondono **503** (non 404, così l'URL non esce dall'indice). - **Render mode** (`app.routes.server.ts`): **tutte `RenderMode.Server`**, niente prerender, perché la configurazione arriva dall'ambiente a runtime e il prerender la fisserebbe alla build. Status 301 sui redirect. **Configurazione runtime** (`core/app-config.ts`, `server/runtime-config.ts`): **la stessa build vale per qualunque dominio**. - Il server legge `SITE_URL`, `PUBLIC_API_URL`, `API_INTERNAL_URL`, `PUBLIC_MEDIA_URL` (origine delle immagini pubbliche per la CSP, default `PUBLIC_API_URL`), `PRIVATE_APP_URL`, `SITE_INDEXABLE`, `STRIPE_PUBLISHABLE_KEY` (`pk_test_…`/`pk_live_…`, validata all'avvio; vuota = checkout senza pagamento), `PORT` (default: `environment.ts`) e `NG_ALLOWED_HOSTS` (letta da Angular). Modello in `apps/public/.env.example`; in sviluppo non serve nessuna variabile. URL non validi → errore all'avvio. - `SITE_CONFIG` (token): sul server viene dall'ambiente e finisce nel **TransferState**; il browser lo rilegge da lì (fallback `environment.ts`). `API_BASE_URL`: sul server è `API_INTERNAL_URL` (es. `http://api:3000` in Docker), nel browser `PUBLIC_API_URL`; se differiscono, `HTTP_TRANSFER_CACHE_ORIGIN_MAP` fa coincidere le chiavi della transfer cache. - **`SITE_INDEXABLE=true` solo in produzione**: altrimenti tutte le pagine hanno `robots: noindex, nofollow` e `robots.txt` è `Disallow: /`. - **Host ammessi** (protezione SSRF di Angular SSR): `angular.json` → `security.allowedHosts` contiene solo `localhost`/`127.0.0.1`; in produzione `NG_ALLOWED_HOSTS` = `DOMAIN`, impostata dal compose. **`ApiService`** (`core/api.service.ts`): `get/post/patch/delete(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 (` | 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, `` (rimossi se assenti). `setJsonLd(id, data)` inserisce `