Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
Deploy stack su Proxmox / deploy (push) Failing after 3s
Deploy stack su Proxmox / deploy (push) Failing after 3s
Monorepo npm con apps/public (Angular SSR), apps/private (Angular SPA), apps/api (Fastify), packages/shared e packages/styles; Medusa 2.21 in medusa/; infrastruttura Docker (Postgres PostGIS+pgvector, Redis, Caddy, backup) e workflow Gitea Actions per il deploy via SSH a ogni push su master. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018jQyT6dR9czzGwqb7FkG6u
This commit is contained in:
@@ -0,0 +1,25 @@
|
||||
# Contesto di build delle immagini api, public e caddy (radice del monorepo).
|
||||
# Medusa ha il suo contesto (medusa/) e il suo .dockerignore.
|
||||
.git
|
||||
**/node_modules
|
||||
**/dist
|
||||
**/.angular
|
||||
**/.turbo
|
||||
**/*.tsbuildinfo
|
||||
**/.env
|
||||
**/.env.*
|
||||
!**/.env.example
|
||||
**/uploads
|
||||
**/logs
|
||||
**/.outbox
|
||||
**/http
|
||||
**/*.private.env.json
|
||||
medusa
|
||||
backups
|
||||
prompts
|
||||
apps/public/src/content/generated
|
||||
apps/public/public/vendor
|
||||
apps/private/public/vendor
|
||||
bash.exe.stackdump
|
||||
.idea
|
||||
.vscode
|
||||
@@ -0,0 +1,12 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
indent_style = space
|
||||
indent_size = 2
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
@@ -0,0 +1,23 @@
|
||||
# Variabili d'ambiente del monorepo ITI TECHNOLOGY.
|
||||
# Copia questo file in `.env` e compila i valori. Non committare mai `.env`.
|
||||
# Ogni nuova variabile introdotta dai prompt va aggiunta qui (con valore di esempio o vuoto).
|
||||
|
||||
# --- Postgres (superuser del container, solo per init/amministrazione) ---
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=change-me
|
||||
POSTGRES_DB=postgres
|
||||
POSTGRES_PORT=5432
|
||||
|
||||
# --- Database applicativo (apps/api) ---
|
||||
APP_DB_NAME=iti_app
|
||||
APP_DB_USER=iti_app
|
||||
APP_DB_PASSWORD=change-me
|
||||
|
||||
# --- Database Medusa ---
|
||||
MEDUSA_DB_NAME=iti_medusa
|
||||
MEDUSA_DB_USER=iti_medusa
|
||||
MEDUSA_DB_PASSWORD=change-me
|
||||
|
||||
# --- Redis ---
|
||||
REDIS_PORT=6379
|
||||
REDIS_URL=redis://localhost:6379
|
||||
@@ -0,0 +1,76 @@
|
||||
# Produzione (docker-compose.prod.yml). Sulla VPS copiare questo file in `.env` nella root del repo e compilarlo.
|
||||
# Non committare mai il file compilato. Procedura completa in CLAUDE.md → "Deploy".
|
||||
#
|
||||
# Segreti: valori casuali lunghi, SOLO esadecimali (finiscono anche dentro URL postgres://), es.
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
# openssl rand -hex 32
|
||||
|
||||
# --- Dominio e TLS ---
|
||||
# Dominio principale, senza www né protocollo. Sottodomini usati: www, area, api, shop-admin, media
|
||||
DOMAIN=example.it
|
||||
# Email per Let's Encrypt (avvisi di scadenza dei certificati)
|
||||
ACME_EMAIL=admin@example.it
|
||||
|
||||
# --- Dashboard Medusa (shop-admin.DOMAIN) ---
|
||||
SHOP_ADMIN_USER=iti
|
||||
# Hash bcrypt della password di basic auth, TRA APICI SINGOLI (contiene "$"):
|
||||
# docker run --rm caddy:2-alpine caddy hash-password --plaintext 'password-lunga'
|
||||
SHOP_ADMIN_PASSWORD_HASH='$2a$14$sostituire-con-hash-bcrypt'
|
||||
# IP/CIDR ammessi, separati da spazio (vuoto = tutti; la basic auth resta comunque)
|
||||
SHOP_ADMIN_ALLOWED_IPS=
|
||||
|
||||
# --- Postgres ---
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=
|
||||
POSTGRES_DB=postgres
|
||||
APP_DB_NAME=iti_app
|
||||
APP_DB_USER=iti_app
|
||||
APP_DB_PASSWORD=
|
||||
MEDUSA_DB_NAME=iti_medusa
|
||||
MEDUSA_DB_USER=iti_medusa
|
||||
MEDUSA_DB_PASSWORD=
|
||||
|
||||
# --- Segreti condivisi API ↔ Medusa ---
|
||||
INTERNAL_API_SECRET=
|
||||
WEBHOOK_SECRET=
|
||||
|
||||
# --- API (apps/api) ---
|
||||
API_COOKIE_SECRET=
|
||||
API_JWT_SECRET=
|
||||
API_LOG_LEVEL=info
|
||||
# Stampata da `npm run deploy:seed:medusa` al primo avvio (riga MEDUSA_PUBLISHABLE_KEY=pk_...)
|
||||
MEDUSA_PUBLISHABLE_KEY=
|
||||
PRIVACY_POLICY_VERSION=privacy-v1
|
||||
# Destinatari delle notifiche interne, separati da virgola (vuoto = utenti admin attivi)
|
||||
ADMIN_NOTIFY_EMAILS=
|
||||
# Email transazionali: console (NON invia: solo log) | brevo | resend
|
||||
EMAIL_PROVIDER=console
|
||||
EMAIL_FROM=
|
||||
EMAIL_FROM_NAME=ITI TECHNOLOGY
|
||||
EMAIL_REPLY_TO=
|
||||
BREVO_API_KEY=
|
||||
RESEND_API_KEY=
|
||||
# Admin iniziale dell'area riservata (`npm run db:seed:prod`), password almeno 12 caratteri
|
||||
SEED_ADMIN_EMAIL=
|
||||
SEED_ADMIN_PASSWORD=
|
||||
|
||||
# --- Medusa ---
|
||||
MEDUSA_JWT_SECRET=
|
||||
MEDUSA_COOKIE_SECRET=
|
||||
MEDUSA_AUTH_MFA_ENCRYPTION_KEY=
|
||||
# Stripe: chiave segreta (sk_live_… / sk_test_…) e signing secret del webhook
|
||||
# https://api.DOMAIN/hooks/payment/stripe_stripe (vedi CLAUDE.md → Stripe)
|
||||
STRIPE_API_KEY=
|
||||
STRIPE_WEBHOOK_SECRET=
|
||||
|
||||
# --- Sito pubblico (apps/public) ---
|
||||
# Chiave pubblicabile Stripe (pk_live_… / pk_test_…), stesso account di STRIPE_API_KEY
|
||||
STRIPE_PUBLISHABLE_KEY=
|
||||
# true solo sul dominio definitivo: altrimenti noindex ovunque e robots.txt "Disallow: /"
|
||||
SITE_INDEXABLE=false
|
||||
|
||||
# --- Backup (npm run backup:prod) ---
|
||||
# Cartella dell'host dove scrivere i backup (da includere nel backup già presente sulla VPS)
|
||||
BACKUP_DIR=./backups
|
||||
# Numero di backup da tenere
|
||||
BACKUP_KEEP_DAYS=14
|
||||
@@ -0,0 +1,7 @@
|
||||
# File eseguiti o letti dentro container Linux: sempre LF, anche con core.autocrlf su Windows
|
||||
*.sh text eol=lf
|
||||
Caddyfile text eol=lf
|
||||
*.caddy text eol=lf
|
||||
*.caddy.example text eol=lf
|
||||
Dockerfile text eol=lf
|
||||
*.sql text eol=lf
|
||||
@@ -0,0 +1,31 @@
|
||||
name: Deploy stack su Proxmox
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
# Sul server DEPLOY_PATH è il clone del repo con il .env di produzione
|
||||
# (da .env.prod.example). Il primo deploy (seed Medusa, admin) si fa a mano:
|
||||
# vedi "Deploy (produzione)" in CLAUDE.md.
|
||||
- name: Deploy via SSH (git pull + docker compose up -d --build)
|
||||
uses: appleboy/ssh-action@v1
|
||||
with:
|
||||
host: ${{ secrets.DEPLOY_HOST }}
|
||||
port: ${{ secrets.DEPLOY_PORT }}
|
||||
username: ${{ secrets.DEPLOY_USER }}
|
||||
key: ${{ secrets.DEPLOY_SSH_KEY }}
|
||||
command_timeout: 45m
|
||||
script: |
|
||||
set -e
|
||||
cd "${{ secrets.DEPLOY_PATH }}"
|
||||
test -f .env || { echo "Manca .env in ${{ secrets.DEPLOY_PATH }} (copiare .env.prod.example)"; exit 1; }
|
||||
git fetch origin master
|
||||
git reset --hard origin/master
|
||||
docker compose -f docker-compose.prod.yml build
|
||||
docker compose -f docker-compose.prod.yml up -d --wait --wait-timeout 600 --remove-orphans
|
||||
docker image prune -f
|
||||
+48
@@ -0,0 +1,48 @@
|
||||
# Dipendenze
|
||||
node_modules/
|
||||
|
||||
# Build
|
||||
dist/
|
||||
.angular/
|
||||
|
||||
# Ambiente / segreti
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
!.env.prod.example
|
||||
|
||||
# Dati runtime
|
||||
uploads/
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# IDE / OS
|
||||
.idea/
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
|
||||
# TypeScript
|
||||
*.tsbuildinfo
|
||||
|
||||
# Turbo / Medusa build
|
||||
.turbo/
|
||||
.medusa/
|
||||
|
||||
# HTTP Client JetBrains: file con segreti
|
||||
*.private.env.json
|
||||
|
||||
# Email salvate in sviluppo (EMAIL_OUTBOX_DIR)
|
||||
.outbox/
|
||||
|
||||
# Indice dei contenuti di apps/public (generato da scripts/content-index.mjs)
|
||||
apps/public/src/content/generated/
|
||||
apps/public/public/vendor/
|
||||
apps/private/public/vendor/
|
||||
|
||||
# Backup di produzione (BACKUP_DIR di default)
|
||||
backups/
|
||||
|
||||
# Dump dei crash di Git Bash su Windows
|
||||
*.stackdump
|
||||
@@ -0,0 +1,33 @@
|
||||
# ITI TECHNOLOGY — Piattaforma web
|
||||
|
||||
Monorepo npm workspaces. Architettura e decisioni: vedi [`CLAUDE.md`](./CLAUDE.md).
|
||||
|
||||
## Struttura
|
||||
|
||||
```
|
||||
apps/
|
||||
api/ → Fastify (backend custom, gateway verso Medusa)
|
||||
public/ → Angular SSR (sito pubblico)
|
||||
private/ → Angular SPA (area clienti / installatori / admin)
|
||||
packages/
|
||||
shared/ → @iti/shared — tipi e DTO condivisi
|
||||
medusa/ → progetto Medusa indipendente (NON incluso nei workspace)
|
||||
prompts/ → prompt di implementazione, da eseguire in ordine
|
||||
```
|
||||
|
||||
## Requisiti
|
||||
|
||||
- Node.js 24 LTS (vedi `.nvmrc`)
|
||||
- npm 11+
|
||||
- Docker Desktop (Compose v2)
|
||||
|
||||
## Avvio
|
||||
|
||||
```powershell
|
||||
Copy-Item .env.example .env # poi imposta le password
|
||||
npm install
|
||||
npm run build:shared
|
||||
npm run infra:up # Postgres (PostGIS + pgvector) e Redis
|
||||
```
|
||||
|
||||
Altri comandi: `npm run infra:logs`, `npm run infra:down`.
|
||||
@@ -0,0 +1,167 @@
|
||||
# Security review pre go-live (prompt 34)
|
||||
|
||||
Data: 2026-10-05. Ambito: `apps/api`, `apps/public`, `apps/private`, `medusa/apps/backend`, `infra/` e `docker-compose.prod.yml`.
|
||||
Metodo: lettura del codice e della configurazione, `npm audit`, test automatici, verifica nel browser (build di produzione del sito, area riservata servita da Caddy con la CSP di produzione).
|
||||
|
||||
Esiti: **ok** = conforme, nessuna modifica · **corretto** = trovato un problema e risolto in questa review · **da fare** = rimane aperto, con motivazione.
|
||||
|
||||
## Riepilogo
|
||||
|
||||
| # | Voce | Esito |
|
||||
|---|---|---|
|
||||
| 1 | Autenticazione, ruoli e ownership delle route `private/` e `admin/` | corretto |
|
||||
| 2 | Input validati da schema, nessuna SQL concatenata | ok |
|
||||
| 3 | Rate limit su auth, form pubblici, configuratore, upload | corretto |
|
||||
| 4 | CORS, cookie, nessun token in localStorage | corretto |
|
||||
| 5 | Upload: whitelist, dimensione, nomi rigenerati, path traversal | ok |
|
||||
| 6 | HTML del blog sanitizzato, nessun `innerHTML`/`bypassSecurityTrust*` su contenuti non sanitizzati | ok |
|
||||
| 7 | CSP senza `'unsafe-inline'` negli script (rimandata dal prompt 33) | corretto |
|
||||
| 8 | Webhook Medusa e Stripe: firma e idempotenza | ok |
|
||||
| 9 | Segreti fuori dal repo e dai log; `npm audit` senza alte/critiche | corretto / da fare |
|
||||
| 10 | Errori in produzione senza stack; log senza password, token, carte | ok |
|
||||
| 11 | Cookie banner: nessun cookie non tecnico prima del consenso | ok |
|
||||
| 12 | Database: utente applicativo non superuser, utenti distinti per app e Medusa | ok |
|
||||
|
||||
## 1. Autenticazione, ruoli e ownership — corretto
|
||||
|
||||
- **Area admin**: tutte le route `/admin/*` stanno dietro un unico hook (`routes/admin/index.ts`). Prima controllava solo il ruolo nel JWT, quindi un admin disabilitato o declassato manteneva l'accesso fino alla scadenza del token (≤ 15 min).
|
||||
**Corretto**: un secondo hook rilegge l'utente dal DB (`requireActiveUser`) e richiede ancora `role = admin`. Disabilitato → 401, declassato → 403, subito. Test: `admin.test.ts` → "admin disabilitato o declassato".
|
||||
- **Route private** (`/me*`, `/installer/profile`): `authenticate` / `requireRole("installer")` più `requireActiveUser` in ogni handler, che rilegge l'utente (disabilitato → 401). Verificato su tutti i file di `routes/private/`.
|
||||
- **Ownership**: preventivi, ticket e messaggi filtrano sempre su `user_id` dell'utente corrente (`mine()`, `loadOwn()`). Risorsa altrui, in bozza o inesistente → 404 identico. Gli ordini Medusa sono filtrati sul customer dell'utente e ricontrollati sul dettaglio (`order.customer_id`). I carrelli sono verificati da `resolveCart`. I documenti seguono `visibleLevels`, riletti dal DB a ogni richiesta. Coperto dai test di `quotes`, `tickets`, `account`, `shop`, `documents`.
|
||||
- **Ruolo admin non assegnabile** dalla registrazione (enum di `accountType`); JWT HS256 con algoritmo fissato in verifica.
|
||||
- **Anti-enumerazione alla registrazione**: con un'email già registrata la risposta era 202 come le altre, ma più veloce (nessun hash argon2), quindi i tempi rivelavano l'email.
|
||||
**Corretto**: anche in quel caso si calcola l'hash (`auth.ts`). Il login usava già un hash fittizio.
|
||||
- Medusa: le route `/internal/*` richiedono `x-internal-secret` (confronto `timingSafeEqual` su digest SHA-256); il dashboard ha whitelist IP e basic auth in Caddy.
|
||||
|
||||
## 2. Validazione e SQL — ok
|
||||
|
||||
- Ogni route Fastify dichiara gli schemi zod di body, query e params (`fastify-type-provider-zod`), più lo schema di risposta. Le route interne di Medusa validano con `validateAndTransformBody`.
|
||||
- Nessun SQL concatenato: Drizzle parametrizza sempre. Nessun uso di `sql.raw`, `.unsafe()` o `execute` con stringhe composte. Le interpolazioni nei template `sql\`…\`` sono parametri (`tsQuery`, advisory lock). Le ricerche `ilike` fanno l'escape di `%`, `_` e `\` (`likeEscape`). Lo stesso vale in Medusa (claim con ILIKE letterale).
|
||||
|
||||
## 3. Rate limit — corretto
|
||||
|
||||
| Route | Limite per IP |
|
||||
|---|---|
|
||||
| globale | 300/min |
|
||||
| login, reset password, verifica email, cambio password, cancellazione account | 5/min |
|
||||
| registrazione, password dimenticata, reinvio verifica | 3 ogni 15 min |
|
||||
| refresh | 30/min |
|
||||
| `POST /contact`, `POST /showroom/bookings`, `POST /configurator/submit` | 5 ogni 15 min |
|
||||
| `POST /configurator/evaluate`, `GET /forms/token` | 60/min |
|
||||
| ticket: nuovo / messaggio | 10/ora, 30/ora |
|
||||
| pagamento e completamento ordine | 20/min |
|
||||
|
||||
**Corretto**:
|
||||
- **Upload admin** (`POST /admin/documents`, `/admin/uploads/image`, `/admin/quotes/:id/pdf`): avevano solo il limite globale. Ora 30/min (`UPLOAD_RATE_LIMIT`).
|
||||
- **`POST /shop/cart/address`**: crea o cerca un customer guest in Medusa per ogni email e registra consensi. Ora 20/min.
|
||||
- **IP reale dietro due proxy**: con `TRUST_PROXY=true` e il server SSR che chiama l'API su `http://api:3000`, tutte le richieste fatte durante il rendering arrivavano dall'IP del container del sito. Così condividevano un unico limite di 300/min: con un po' di traffico il sito avrebbe ricevuto 429 dall'API.
|
||||
- L'API ora accetta in `TRUST_PROXY` un elenco di proxy fidati. In produzione vale `loopback,uniquelocal`, cioè si fida di `X-Forwarded-For` solo dai container della rete Docker. Un client diretto non può falsificarlo, e Caddy sostituisce l'intestazione dei client.
|
||||
- Il server SSR inoltra l'`X-Forwarded-For` ricevuto da Caddy (`forwarded-for.interceptor.ts`), così ogni visitatore ha il suo limite.
|
||||
- Test: `app.test.ts` (IP da proxy fidato e da client diretto).
|
||||
- Nota: il contatore del rate limit è in memoria, per processo. Va bene con una sola istanza dell'API; con più istanze serve lo store Redis di `@fastify/rate-limit`.
|
||||
|
||||
## 4. CORS, cookie, storage — corretto
|
||||
|
||||
- CORS: solo `CORS_ORIGINS` (sito e area riservata) con credenziali; il webhook ha `cors: false`.
|
||||
- Cookie:
|
||||
|
||||
| Cookie | Attributi |
|
||||
|---|---|
|
||||
| `iti_rt` | `HttpOnly`, `Secure`, `SameSite=Lax`, `Path=/auth` |
|
||||
| `cart_id` | firmato, `HttpOnly`, `Secure`, `SameSite=Lax` |
|
||||
| `iti_session` | non httpOnly (voluto: solo un indizio, valore `1`, nessun segreto) |
|
||||
| `iti_consent` | non httpOnly (voluto: lo legge il banner) |
|
||||
|
||||
- Token: access token solo in memoria in entrambe le app. In `localStorage` e `sessionStorage` ci sono solo configuratore, ultimo ordine e riepilogo dell'invio, senza token (verificato con grep e nel browser).
|
||||
- **Corretto**: in produzione l'API ora **rifiuta di partire** con:
|
||||
- `AUTH_COOKIE_SECURE=false`;
|
||||
- origini CORS non `https://`;
|
||||
- `EMAIL_OUTBOX_DIR` impostata, che salverebbe su disco le email complete con i link di reset e di verifica, cioè token validi.
|
||||
|
||||
Test in `app.test.ts`.
|
||||
|
||||
## 5. Upload — ok
|
||||
|
||||
- Whitelist per estensione **e** magic bytes **e** MIME dichiarato (SVG e HTML mai ammessi), `UPLOAD_MAX_BYTES` con conteggio in streaming (413) e cancellazione del file parziale.
|
||||
- Chiavi generate dal server (`<uuid><ext>`), doppio controllo anti path traversal, scrittura con `wx`.
|
||||
- `private/` mai servita staticamente: Caddy monta solo `uploads/public`. `media.<dominio>` ha `CSP sandbox` e `nosniff`.
|
||||
- Coperto da `documents.test.ts` (14 casi) e dalla verifica del prompt 33.
|
||||
|
||||
## 6. HTML del blog e frontend — ok
|
||||
|
||||
- L'API converte il markdown con `marked` e poi lo filtra con `sanitize-html` (whitelist; niente `script`, `iframe`, `style`, `on*`, `javascript:`, `data:`).
|
||||
- Nei frontend ci sono solo due `bypassSecurityTrustHtml`, entrambi su HTML che arriva da quel sanitizer: articolo del blog (`blog-post.ts`) e anteprima dell'editor (`blog-editor.ts`).
|
||||
- Nessun altro `innerHTML`, `outerHTML` o `insertAdjacentHTML`.
|
||||
- Verificato nel browser: markdown con `<img onerror>` e `<script>` nell'anteprima → tag rimossi, nessun codice eseguito.
|
||||
|
||||
## 7. Content-Security-Policy — corretto
|
||||
|
||||
Era il punto rimandato dal prompt 33: `'unsafe-inline'` negli script di entrambe le app.
|
||||
|
||||
**Sito (SSR) — nonce per risposta** (`apps/public/src/server/csp.ts`):
|
||||
- `src/index.html` ha `ngCspNonce="__CSP_NONCE__"`. La build mette quel segnaposto come `nonce` sugli script e sugli stili inline e lo passa a beasties (CSS critici). Durante l'SSR, `CSP_NONCE` di Angular legge lo stesso attributo per lo script di avvio dell'event replay.
|
||||
- Il server Express sostituisce il segnaposto con 128 bit casuali per ogni risposta HTML e invia l'intestazione CSP: `script-src 'self' 'nonce-…' https://js.stripe.com https://*.js.stripe.com`, `connect-src` sull'API e Stripe, `img-src` su `PUBLIC_MEDIA_URL` (nuova variabile) e tile OSM, `frame-ancestors 'none'`.
|
||||
- Con `ng serve` la CSP non viene inviata.
|
||||
- Caddy non sovrascrive più la CSP del sito. Imposta solo un default restrittivo (`?Content-Security-Policy`) per le risposte che non ce l'hanno: statici ed errori. Verificato con Caddy reale: la CSP con nonce passa invariata, gli statici ricevono il default.
|
||||
|
||||
**Area riservata** (statica): `inlineCritical: false` nella build di produzione, quindi l'`index.html` non contiene più script inline. CSP `script-src 'self'`.
|
||||
|
||||
**Verificato nel browser**:
|
||||
- sito (build di produzione + API e Medusa reali): idratazione, stylesheet attivato dallo script di beasties con il nonce, aggiunta al carrello funzionante; uno script inline iniettato viene **bloccato** (`script-src-elem`);
|
||||
- area riservata servita da Caddy con la CSP di produzione: login, dashboard admin ed editor del blog senza violazioni.
|
||||
|
||||
`style-src 'unsafe-inline'` resta: servono gli stili dei componenti Angular e gli attributi `style` di Leaflet. Il rischio è basso, perché gli script restano bloccati.
|
||||
|
||||
## 8. Webhook — ok
|
||||
|
||||
- **Medusa → Fastify**: HMAC-SHA256 sul body grezzo verificato prima del parse, con `timingSafeEqual`. Idempotenza su `webhook_events` (id UUID v5 deterministico, lock `FOR UPDATE`). Coperto da `webhooks.test.ts` (13 casi).
|
||||
- **Stripe → Medusa**: la firma la verifica il provider ufficiale `payment-stripe` (`webhookSecret`). Caddy espone solo `/hooks/payment/stripe_stripe`. L'elaborazione di Medusa è idempotente sullo stato della payment session (un evento ripetuto non cattura due volte).
|
||||
- **Corretto**: in produzione Medusa ora avvisa all'avvio se c'è `STRIPE_API_KEY` ma manca `STRIPE_WEBHOOK_SECRET`.
|
||||
- **Da fare**: il flusso Stripe reale (pagamento, 3DS, webhook firmato) non è ancora verificato end-to-end. Servono le chiavi di test (vedi prompt 13 e 26).
|
||||
|
||||
## 9. Segreti e dipendenze — corretto / da fare
|
||||
|
||||
- **Repo**: i file `.env*` (tranne gli `.example`), `*.private.env.json`, `uploads/`, `backups/` e `.outbox/` sono in `.gitignore`. Una ricerca di chiavi Stripe, Brevo, Resend e PEM tra i file che finirebbero nel repo non trova nulla; gli `.example` hanno solo segnaposto.
|
||||
**Corretto**: aggiunto `*.stackdump` (dump dei crash di Git Bash, due file non tracciati nel working tree).
|
||||
- **Log**: redatti `authorization`, `cookie`, `set-cookie`, `x-internal-secret`, `x-signature`. I body non vengono mai loggati. I token viaggiano solo nel body o nei cookie, mai negli URL dell'API. Il trasporto email `console` logga solo destinatario, template e oggetto.
|
||||
- **Corretto**: in produzione `medusa-config.ts` ora 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 il default `supersecret`.
|
||||
- **`npm audit` del monorepo**: 0 vulnerabilità nelle dipendenze di produzione. Ci sono 4 *moderate* solo di sviluppo (`esbuild` dentro `drizzle-kit`, server di sviluppo mai avviato; il fix proposto è un downgrade).
|
||||
- **`npm audit` di Medusa**: prima 110 *high*, tutte transitive dal pacchetto `@medusajs/*`, da due sole cause.
|
||||
- **lodash ≤ 4.17.23** (prototype pollution in `_.unset`/`_.omit`, code injection in `_.template`). **Corretto**: `overrides.lodash = ^4.18.1` in `medusa/package.json` e lockfile aggiornato. Una sola copia, 4.18.1; Medusa si avvia e interroga il DB (`medusa exec` verificato).
|
||||
- **braces ≤ 3.0.3** (stack exhaustion con pattern glob annidati). **Da fare**: non esiste una versione corretta (3.0.3 è l'ultima). Il pacchetto arriva da `fast-glob`/`micromatch` (caricamento dei moduli all'avvio) e dal bundler del dashboard (build). Non riceve mai pattern da input esterni, quindi non è sfruttabile. Le 77 *high* di produzione che restano sono tutte questa catena. Da ricontrollare a ogni aggiornamento di Medusa.
|
||||
|
||||
## 10. Errori e log in produzione — ok
|
||||
|
||||
- Mai stack trace nelle risposte. I 5xx rispondono `"Errore interno"` in produzione (test `app.test.ts`); gli errori di Medusa diventano 503/502 generici, con il dettaglio solo nei log.
|
||||
- Password: solo hash argon2id. Token: solo SHA-256 in DB (test `auth.test.ts`: nessun segreto nel dump del DB né nei log catturati).
|
||||
- Dati carta: non passano mai dai server, perché il Payment Element di Stripe li invia direttamente a Stripe.
|
||||
|
||||
## 11. Cookie banner — ok
|
||||
|
||||
Verificato nel prompt 28 e invariato:
|
||||
- alla prima visita nessun cookie e nessuna risorsa di terzi;
|
||||
- `iti_consent` viene scritto solo dopo la scelta;
|
||||
- i cookie di sessione e carrello sono tecnici, e nascono solo con login o aggiunta al carrello;
|
||||
- Stripe si carica solo al passo di pagamento (necessario al servizio richiesto);
|
||||
- le tile OSM si caricano solo dopo il clic su "Mostra mappa".
|
||||
|
||||
Oggi il sito non ha script di statistica o marketing; `ConsentService` è il punto da usare per quelli futuri.
|
||||
|
||||
## 12. Database — ok
|
||||
|
||||
- `iti_app` e `iti_medusa` sono utenti distinti. Nessuno dei due è superuser e non hanno `CREATEROLE`, `CREATEDB` né `BYPASSRLS` (verificato su `pg_roles`).
|
||||
- Ciascuno è proprietario solo del proprio database; `CONNECT` è revocato a `PUBLIC`.
|
||||
- Le estensioni le crea l'init del container (superuser), non le migrazioni.
|
||||
- Postgres e Redis stanno sulla rete Docker `internal`, senza porte pubblicate.
|
||||
- **Da fare** (minore): l'init crea anche `iti_app_test` in produzione, dove non serve. Dopo il primo deploy si può eliminare con `DROP DATABASE iti_app_test`.
|
||||
|
||||
## Punti rimasti aperti
|
||||
|
||||
1. **Stripe end-to-end** (pagamento, 3DS, carta rifiutata, webhook firmato): non verificato, mancano le chiavi di test (prompt 13 e 26).
|
||||
2. **braces** nelle dipendenze di Medusa: nessuna correzione upstream, non sfruttabile; ricontrollare agli aggiornamenti di Medusa.
|
||||
3. **Rate limit in memoria**: con più istanze dell'API, passare allo store Redis.
|
||||
4. **HTTPS reale e HSTS**: certificati Let's Encrypt e DNS da verificare sulla VPS. HSTS è senza `includeSubDomains`/`preload`, una scelta voluta (prompt 33).
|
||||
5. **Immagini esterne negli articoli del blog**: la CSP le blocca (`img-src` limitato a self, media, OSM e Stripe). Le immagini vanno caricate dall'editor.
|
||||
6. **GDPR**: la cancellazione effettiva dei dati dopo la richiesta dell'utente è manuale; privacy e cookie policy sono da validare con il consulente.
|
||||
7. **`iti_app_test` in produzione**: da eliminare dopo il primo deploy.
|
||||
8. **Utenti del dashboard Medusa**: password robuste e whitelist IP (`SHOP_ADMIN_ALLOWED_IPS`) sull'ufficio o sulla VPN; il default ammette tutti, protetti solo dalla basic auth.
|
||||
@@ -0,0 +1,91 @@
|
||||
# apps/api (Fastify). Copia in `.env` e compila. Non committare `.env`.
|
||||
# La configurazione è validata all'avvio (src/config/env.ts): se manca qualcosa il server non parte
|
||||
# e stampa l'elenco delle variabili mancanti o non valide.
|
||||
|
||||
# --- Server ---
|
||||
NODE_ENV=development
|
||||
HOST=0.0.0.0
|
||||
# 3000: è la porta a cui Medusa invia i webhook (FASTIFY_WEBHOOK_URL in medusa/apps/backend/.env)
|
||||
PORT=3000
|
||||
LOG_LEVEL=info
|
||||
# Cartella dei log su file con rotazione (obbligatoria con NODE_ENV=production)
|
||||
LOG_DIR=
|
||||
# Proxy fidati per l'IP reale da X-Forwarded-For: false, true, oppure indirizzi/CIDR/preset separati da virgola
|
||||
# (produzione: "loopback,uniquelocal", cioè Caddy e il server SSR sulla rete Docker)
|
||||
TRUST_PROXY=false
|
||||
|
||||
# --- Database applicativo ---
|
||||
# Postgres iti_app con l'utente iti_app (credenziali APP_DB_* del .env di root)
|
||||
DATABASE_URL=postgres://iti_app:change-me@localhost:5432/iti_app
|
||||
|
||||
# --- Seed (npm run db:seed): utente admin iniziale, password almeno 12 caratteri ---
|
||||
SEED_ADMIN_EMAIL=
|
||||
SEED_ADMIN_PASSWORD=
|
||||
|
||||
# --- Sicurezza ---
|
||||
# Origini CORS consentite, separate da virgola (sito pubblico + area privata)
|
||||
CORS_ORIGINS=http://localhost:4200,http://localhost:4300
|
||||
# Segreto per i cookie firmati (almeno 32 caratteri)
|
||||
# node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
|
||||
COOKIE_SECRET=
|
||||
# Rate limit globale per IP
|
||||
RATE_LIMIT_MAX=300
|
||||
RATE_LIMIT_WINDOW=1 minute
|
||||
|
||||
# --- Autenticazione ---
|
||||
# Segreto HS256 degli access token (almeno 32 caratteri)
|
||||
JWT_SECRET=
|
||||
# Durata access token (secondi) e refresh token (giorni)
|
||||
ACCESS_TOKEN_TTL_SECONDS=900
|
||||
REFRESH_TOKEN_TTL_DAYS=30
|
||||
# Dominio padre del cookie di refresh per condividerlo tra sottodomini (es. .ititechnology.it); vuoto in sviluppo
|
||||
AUTH_COOKIE_DOMAIN=
|
||||
AUTH_COOKIE_SECURE=true
|
||||
# Limiti per IP: login/reset al minuto, route che inviano email ogni 15 minuti
|
||||
AUTH_LOGIN_RATE_MAX=5
|
||||
AUTH_EMAIL_RATE_MAX=3
|
||||
# URL dell'area privata (base dei link nelle email di verifica/reset)
|
||||
PRIVATE_APP_URL=http://localhost:4300
|
||||
# Versione dell'informativa privacy salvata con i consensi
|
||||
PRIVACY_POLICY_VERSION=privacy-v1
|
||||
# Destinatari delle notifiche interne (nuovo ordine), separati da virgola; vuoto = utenti admin attivi
|
||||
ADMIN_NOTIFY_EMAILS=
|
||||
# --- Storage file (disco) ---
|
||||
# Radice: contiene public/ (servita da Caddy) e private/ (mai servita staticamente, solo via API).
|
||||
# In produzione un percorso assoluto su volume Docker.
|
||||
UPLOAD_DIR=uploads
|
||||
# Dimensione massima di un upload in byte (25 MB)
|
||||
UPLOAD_MAX_BYTES=26214400
|
||||
# URL pubblico dell'area public/ (in sviluppo la serve l'API stessa su /uploads)
|
||||
PUBLIC_UPLOADS_URL=http://localhost:3000/uploads
|
||||
|
||||
# --- Email transazionali ---
|
||||
# Provider: console (solo log + EMAIL_OUTBOX_DIR), brevo, resend. Cambiare provider = cambiare queste variabili.
|
||||
EMAIL_PROVIDER=console
|
||||
# Mittente su dominio verificato presso il provider (obbligatorio con brevo/resend)
|
||||
EMAIL_FROM=
|
||||
EMAIL_FROM_NAME=ITI TECHNOLOGY
|
||||
EMAIL_REPLY_TO=
|
||||
# Chiave del provider scelto
|
||||
BREVO_API_KEY=
|
||||
RESEND_API_KEY=
|
||||
# Solo sviluppo: le email (adapter console) vengono salvate complete in questa cartella
|
||||
EMAIL_OUTBOX_DIR=
|
||||
|
||||
# --- Medusa ---
|
||||
# URL del backend Medusa (in Docker: http://medusa:9000) e timeout di ogni chiamata in ms
|
||||
MEDUSA_URL=http://localhost:9000
|
||||
MEDUSA_TIMEOUT_MS=5000
|
||||
# Payment provider del checkout (Stripe registrato in medusa-config.ts con id "stripe")
|
||||
MEDUSA_PAYMENT_PROVIDER_ID=pp_stripe_stripe
|
||||
# Publishable API key del sales channel "Web": la stampa a console lo script seed
|
||||
# (in medusa/apps/backend: npx medusa exec ./src/scripts/seed-base.ts), riga MEDUSA_PUBLISHABLE_KEY=pk_...
|
||||
MEDUSA_PUBLISHABLE_KEY=
|
||||
|
||||
# Segreto condiviso con Medusa per le route /internal/* (header x-internal-secret). Deve coincidere con
|
||||
# INTERNAL_API_SECRET in medusa/apps/backend/.env. Generare un valore casuale lungo (almeno 32 caratteri).
|
||||
INTERNAL_API_SECRET=
|
||||
|
||||
# Segreto per verificare la firma HMAC-SHA256 (header x-signature) dei webhook Medusa su /webhooks/medusa.
|
||||
# Deve coincidere con WEBHOOK_SECRET in medusa/apps/backend/.env (almeno 32 caratteri).
|
||||
WEBHOOK_SECRET=
|
||||
@@ -0,0 +1,50 @@
|
||||
# syntax=docker/dockerfile:1
|
||||
# API Fastify (apps/api). Contesto di build: radice del monorepo.
|
||||
# docker build -f apps/api/Dockerfile -t iti-technology/api .
|
||||
# All'avvio applica le migrazioni (idempotenti), poi avvia il server.
|
||||
|
||||
ARG NODE_IMAGE=node:24-bookworm-slim
|
||||
|
||||
# --- Dipendenze (tutte, per compilare) ---
|
||||
FROM ${NODE_IMAGE} AS deps
|
||||
WORKDIR /repo
|
||||
# Tutti i manifest dei workspace: npm ci valida il lockfile sull'intero monorepo
|
||||
COPY package.json package-lock.json tsconfig.base.json ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/public/package.json apps/public/
|
||||
COPY apps/private/package.json apps/private/
|
||||
# --ignore-scripts: i postinstall di root e delle app Angular servono solo in sviluppo
|
||||
RUN npm ci --ignore-scripts --no-audit --no-fund -w @iti/api -w @iti/shared --include-workspace-root
|
||||
|
||||
# --- Build ---
|
||||
FROM deps AS build
|
||||
COPY packages/shared packages/shared
|
||||
COPY apps/api apps/api
|
||||
RUN npm run build -w @iti/shared && npx tsc -p apps/api/tsconfig.build.json
|
||||
|
||||
# --- Runtime: solo dipendenze di produzione + JS compilato ---
|
||||
FROM ${NODE_IMAGE} AS runtime
|
||||
ENV NODE_ENV=production
|
||||
WORKDIR /repo
|
||||
COPY package.json package-lock.json ./
|
||||
COPY packages/shared/package.json packages/shared/
|
||||
COPY apps/api/package.json apps/api/
|
||||
COPY apps/public/package.json apps/public/
|
||||
COPY apps/private/package.json apps/private/
|
||||
RUN npm ci --omit=dev --ignore-scripts --no-audit --no-fund -w @iti/api -w @iti/shared \
|
||||
&& npm cache clean --force \
|
||||
# Volumi: uploads (condiviso con Caddy, che serve solo public/) e log su file
|
||||
&& mkdir -p /data/uploads/public /data/uploads/private /var/log/iti \
|
||||
&& chown -R node:node /data/uploads /var/log/iti
|
||||
COPY --from=build /repo/packages/shared/dist packages/shared/dist
|
||||
COPY --from=build /repo/apps/api/dist apps/api/dist
|
||||
# Il migrator cerca le migrazioni in src/db/migrations rispetto alla root del package
|
||||
COPY apps/api/src/db/migrations apps/api/src/db/migrations
|
||||
|
||||
USER node
|
||||
WORKDIR /repo/apps/api
|
||||
EXPOSE 3000
|
||||
HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=5 \
|
||||
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r=>process.exit(r.ok?0:1),()=>process.exit(1))"]
|
||||
CMD ["sh", "-c", "node dist/db/migrate.js && exec node dist/server.js"]
|
||||
@@ -0,0 +1,16 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { defineConfig } from "drizzle-kit"
|
||||
|
||||
// drizzle-kit (db:generate) legge solo DATABASE_URL: carica apps/api/.env se presente
|
||||
if (existsSync(".env")) process.loadEnvFile(".env")
|
||||
|
||||
export default defineConfig({
|
||||
dialect: "postgresql",
|
||||
schema: "./src/db/schema/index.ts",
|
||||
out: "./src/db/migrations",
|
||||
dbCredentials: { url: process.env.DATABASE_URL ?? "" },
|
||||
// spatial_ref_sys & co. appartengono a PostGIS (creata dall'init del container), non allo schema app
|
||||
extensionsFilters: ["postgis"],
|
||||
strict: true,
|
||||
verbose: true,
|
||||
})
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dev": {
|
||||
"baseUrl": "http://localhost:3000",
|
||||
"productHandle": "pompa-di-calore-aria-acqua-8kw",
|
||||
"guestEmail": "checkout-guest@example.com"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"dev": {
|
||||
"installerEmail": "installatore-approvato@example.com",
|
||||
"installerPassword": "password-dell-installatore",
|
||||
"stripeSecretKey": "sk_test_..."
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
### Proxy shop (/shop/*) — HTTP Client JetBrains/WebStorm, ambiente "dev".
|
||||
### Variabili: http-client.env.json + http-client.private.env.json (non committato, modello in
|
||||
### http-client.private.env.example.json). Il client conserva i cookie (cart_id) tra le richieste.
|
||||
### Prerequisiti: infra su, Medusa avviato con una chiave Stripe di TEST reale (STRIPE_API_KEY), apps/api avviata.
|
||||
### Eseguire in ordine. Parte A: checkout guest completo. Parte B: installatore approvato (prezzi riservati).
|
||||
|
||||
### A0. Catalogo
|
||||
GET {{baseUrl}}/shop/products?limit=12
|
||||
|
||||
### A1. Dettaglio prodotto (prezzo standard in centesimi: 549000)
|
||||
GET {{baseUrl}}/shop/products/{{productHandle}}
|
||||
|
||||
> {%
|
||||
client.global.set("variantId", response.body.variants[0].id)
|
||||
client.test("prezzo standard", () => client.assert(response.body.variants[0].price === 549000))
|
||||
%}
|
||||
|
||||
### A2. Categorie
|
||||
GET {{baseUrl}}/shop/categories
|
||||
|
||||
### A3. Crea il carrello (cookie cart_id httpOnly)
|
||||
POST {{baseUrl}}/shop/cart
|
||||
|
||||
### A4. Aggiungi il prodotto
|
||||
POST {{baseUrl}}/shop/cart/items
|
||||
Content-Type: application/json
|
||||
|
||||
{ "variantId": "{{variantId}}", "quantity": 1 }
|
||||
|
||||
> {% client.global.set("itemId", response.body.items[0].id) %}
|
||||
|
||||
### A5. Cambia quantità
|
||||
PATCH {{baseUrl}}/shop/cart/items/{{itemId}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "quantity": 1 }
|
||||
|
||||
### A6. Email (obbligatoria per il guest) e indirizzo; fatturazione = spedizione
|
||||
POST {{baseUrl}}/shop/cart/address
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"email": "{{guestEmail}}",
|
||||
"shippingAddress": {
|
||||
"firstName": "Mario", "lastName": "Rossi", "address1": "Via Roma 1",
|
||||
"city": "Milano", "postalCode": "20100", "province": "MI", "phone": "3330000000"
|
||||
},
|
||||
"invoice": { "companyName": "Rossi Srl", "vatNumber": "00743110157", "sdiCode": "0000000" },
|
||||
"acceptPrivacy": true
|
||||
}
|
||||
|
||||
### A7. Opzioni di spedizione
|
||||
GET {{baseUrl}}/shop/cart/shipping-options
|
||||
|
||||
> {% client.global.set("shippingOptionId", response.body[0].id) %}
|
||||
|
||||
### A8. Scegli la spedizione → readyForPayment = true
|
||||
POST {{baseUrl}}/shop/cart/shipping
|
||||
Content-Type: application/json
|
||||
|
||||
{ "optionId": "{{shippingOptionId}}" }
|
||||
|
||||
> {% client.test("pronto per il pagamento", () => client.assert(response.body.readyForPayment === true)) %}
|
||||
|
||||
### A9. Payment session Stripe → client_secret
|
||||
POST {{baseUrl}}/shop/cart/payment
|
||||
|
||||
> {%
|
||||
client.global.set("clientSecret", response.body.clientSecret)
|
||||
client.global.set("paymentIntentId", response.body.clientSecret.split("_secret_")[0])
|
||||
%}
|
||||
|
||||
### A10. Conferma con carta di test (al posto di Stripe.js nel browser): API Stripe con la chiave segreta di TEST
|
||||
POST https://api.stripe.com/v1/payment_intents/{{paymentIntentId}}/confirm
|
||||
Authorization: Bearer {{stripeSecretKey}}
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
payment_method=pm_card_visa&return_url=https%3A%2F%2Fexample.com%2Fcheckout
|
||||
|
||||
### A11. Completa → ordine creato, cookie cart_id cancellato
|
||||
POST {{baseUrl}}/shop/cart/complete
|
||||
|
||||
> {% client.test("ordine creato", () => client.assert(response.status === 200 && response.body.number > 0)) %}
|
||||
|
||||
### A12. Dopo il completamento non c'è più un carrello → 404
|
||||
GET {{baseUrl}}/shop/cart
|
||||
|
||||
### B1. Carrello guest prima del login (prezzo standard)
|
||||
POST {{baseUrl}}/shop/cart/items
|
||||
Content-Type: application/json
|
||||
|
||||
{ "variantId": "{{variantId}}", "quantity": 1 }
|
||||
|
||||
### B2. Login installatore approvato: il carrello guest aperto viene associato
|
||||
POST {{baseUrl}}/auth/login
|
||||
Content-Type: application/json
|
||||
|
||||
{ "email": "{{installerEmail}}", "password": "{{installerPassword}}" }
|
||||
|
||||
> {% client.global.set("accessToken", response.body.accessToken) %}
|
||||
|
||||
### B3. Carrello riprezzato col listino installatori (465000)
|
||||
GET {{baseUrl}}/shop/cart
|
||||
Authorization: Bearer {{accessToken}}
|
||||
|
||||
> {% client.test("prezzo installatore", () => client.assert(response.body.items[0].unitPrice === 465000)) %}
|
||||
|
||||
### B4. Catalogo con prezzi riservati (priceType "installer", originalPrice = listino pubblico)
|
||||
GET {{baseUrl}}/shop/products/{{productHandle}}
|
||||
Authorization: Bearer {{accessToken}}
|
||||
|
||||
### B5. Indirizzo (email dell'account, non serve inviarla)
|
||||
POST {{baseUrl}}/shop/cart/address
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"shippingAddress": {
|
||||
"firstName": "Ina", "lastName": "Stallatore", "company": "Impianti Srl", "address1": "Via Milano 10",
|
||||
"city": "Torino", "postalCode": "10100", "province": "TO"
|
||||
},
|
||||
"acceptPrivacy": true
|
||||
}
|
||||
|
||||
### B6. Spedizione
|
||||
POST {{baseUrl}}/shop/cart/shipping
|
||||
Authorization: Bearer {{accessToken}}
|
||||
Content-Type: application/json
|
||||
|
||||
{ "optionId": "{{shippingOptionId}}" }
|
||||
|
||||
### B7. Pagamento
|
||||
POST {{baseUrl}}/shop/cart/payment
|
||||
Authorization: Bearer {{accessToken}}
|
||||
|
||||
> {% client.global.set("paymentIntentId", response.body.clientSecret.split("_secret_")[0]) %}
|
||||
|
||||
### B8. Conferma carta di test
|
||||
POST https://api.stripe.com/v1/payment_intents/{{paymentIntentId}}/confirm
|
||||
Authorization: Bearer {{stripeSecretKey}}
|
||||
Content-Type: application/x-www-form-urlencoded
|
||||
|
||||
payment_method=pm_card_visa&return_url=https%3A%2F%2Fexample.com%2Fcheckout
|
||||
|
||||
### B9. Completa → ordine con prezzi installatore, collegato al customer dell'account
|
||||
POST {{baseUrl}}/shop/cart/complete
|
||||
Authorization: Bearer {{accessToken}}
|
||||
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@iti/api",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Backend custom ITI TECHNOLOGY (Fastify) — unico gateway verso Medusa",
|
||||
"type": "module",
|
||||
"main": "dist/server.js",
|
||||
"scripts": {
|
||||
"prebuild": "npm run build -w @iti/shared",
|
||||
"dev": "tsx watch src/server.ts",
|
||||
"build": "tsc -p tsconfig.build.json",
|
||||
"start": "node dist/server.js",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "vitest run",
|
||||
"db:generate": "drizzle-kit generate",
|
||||
"db:migrate": "tsx src/db/migrate.ts",
|
||||
"db:seed": "tsx src/db/seed.ts",
|
||||
"db:studio": "drizzle-kit studio",
|
||||
"email:preview": "tsx src/emails/preview.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@fastify/cookie": "^11.1.2",
|
||||
"@fastify/cors": "^11.3.0",
|
||||
"@fastify/helmet": "^13.1.1",
|
||||
"@fastify/jwt": "^10.2.2",
|
||||
"@fastify/multipart": "^10.1.2",
|
||||
"@fastify/rate-limit": "^11.2.0",
|
||||
"@fastify/static": "^10.1.5",
|
||||
"@iti/shared": "*",
|
||||
"@node-rs/argon2": "^2.2.1",
|
||||
"drizzle-orm": "^0.45.3",
|
||||
"fastify": "^5.12.5",
|
||||
"fastify-plugin": "^6.0.0",
|
||||
"fastify-type-provider-zod": "^7.0.0",
|
||||
"marked": "^18.0.14",
|
||||
"pino": "^10.4.0",
|
||||
"pino-roll": "^4.0.0",
|
||||
"postgres": "^3.4.9",
|
||||
"sanitize-html": "^2.18.0",
|
||||
"zod": "^4.6.5"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^24.19.1",
|
||||
"@types/sanitize-html": "^2.16.2",
|
||||
"drizzle-kit": "^0.31.11",
|
||||
"pino-pretty": "^13.1.3",
|
||||
"tsx": "^4.23.15",
|
||||
"vitest": "^5.0.3"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import Fastify, { type FastifyServerOptions } from "fastify"
|
||||
import {
|
||||
serializerCompiler,
|
||||
validatorCompiler,
|
||||
type ZodTypeProvider,
|
||||
} from "fastify-type-provider-zod"
|
||||
import type { Config } from "./config/env.js"
|
||||
import { createEmailTransport, EmailSender, type EmailService } from "./services/email.js"
|
||||
import type { FetchFn } from "./services/medusa-client.js"
|
||||
import type { StorageService } from "./services/storage.js"
|
||||
import { buildLoggerOptions } from "./config/logger.js"
|
||||
import authPlugin from "./plugins/auth.js"
|
||||
import configPlugin from "./plugins/config.js"
|
||||
import dbPlugin from "./plugins/db.js"
|
||||
import medusaClientPlugin from "./plugins/medusa-client.js"
|
||||
import shopPlugin from "./plugins/shop.js"
|
||||
import storagePlugin from "./plugins/storage.js"
|
||||
import errorHandler from "./plugins/error-handler.js"
|
||||
import security from "./plugins/security.js"
|
||||
import { registerRoutes } from "./routes/index.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** Invio email transazionali (adapter sostituibile, vedi services/email.ts). */
|
||||
email: EmailService
|
||||
}
|
||||
}
|
||||
|
||||
/** Dipendenze sostituibili (es. nei test). */
|
||||
export type AppDeps = {
|
||||
email?: EmailService
|
||||
/** Storage alternativo al disco locale (es. S3 in futuro, o nei test). */
|
||||
storage?: StorageService
|
||||
/** fetch usato dal client Medusa (nei test: Medusa mockato). */
|
||||
medusaFetch?: FetchFn
|
||||
/** Sostituisce le opzioni del logger (nei test: stream in memoria per ispezionare i log). */
|
||||
logger?: FastifyServerOptions["logger"]
|
||||
}
|
||||
|
||||
/** Costruisce l'istanza Fastify (senza metterla in ascolto): usata da server.ts e dai test. */
|
||||
export async function buildApp(config: Config, deps: AppDeps = {}) {
|
||||
const app = Fastify({
|
||||
logger: deps.logger ?? buildLoggerOptions(config),
|
||||
trustProxy: config.TRUST_PROXY,
|
||||
requestIdHeader: "x-request-id",
|
||||
// Body JSON fino a 1 MB; gli upload useranno multipart con limiti propri
|
||||
bodyLimit: 1_048_576,
|
||||
}).withTypeProvider<ZodTypeProvider>()
|
||||
|
||||
// Validazione input e serializzazione output con schemi zod
|
||||
app.setValidatorCompiler(validatorCompiler)
|
||||
app.setSerializerCompiler(serializerCompiler)
|
||||
|
||||
await app.register(configPlugin, { config })
|
||||
await app.register(errorHandler)
|
||||
await app.register(dbPlugin)
|
||||
await app.register(medusaClientPlugin, { fetch: deps.medusaFetch })
|
||||
await app.register(shopPlugin)
|
||||
await app.register(security)
|
||||
await app.register(storagePlugin, { storage: deps.storage })
|
||||
await app.register(authPlugin)
|
||||
const email = deps.email ?? new EmailSender(createEmailTransport(config, app.log), app.log)
|
||||
app.decorate("email", email)
|
||||
// Alla chiusura attende le email ancora in consegna
|
||||
app.addHook("onClose", async () => {
|
||||
await email.flush()
|
||||
})
|
||||
if (config.NODE_ENV === "production" && config.EMAIL_PROVIDER === "console") {
|
||||
app.log.warn("EMAIL_PROVIDER=console in produzione: le email NON vengono inviate")
|
||||
}
|
||||
await registerRoutes(app)
|
||||
|
||||
return app
|
||||
}
|
||||
|
||||
export type App = Awaited<ReturnType<typeof buildApp>>
|
||||
@@ -0,0 +1,197 @@
|
||||
import { existsSync } from "node:fs"
|
||||
import { isAbsolute } from "node:path"
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { z } from "zod"
|
||||
|
||||
/** apps/api/.env (stesso percorso relativo da src/config e da dist/config). */
|
||||
const ENV_FILE = fileURLToPath(new URL("../../.env", import.meta.url))
|
||||
|
||||
const booleanFromEnv = z
|
||||
.enum(["true", "false", "1", "0"])
|
||||
.transform((v) => v === "true" || v === "1")
|
||||
|
||||
/**
|
||||
* TRUST_PROXY → opzione trustProxy di Fastify: "true"/"false" oppure indirizzi/CIDR/preset
|
||||
* di proxy-addr separati da virgola (es. "loopback,uniquelocal": fidati solo dei container sulla rete Docker).
|
||||
*/
|
||||
const trustProxyFromEnv = z
|
||||
.string()
|
||||
.trim()
|
||||
.transform((v): boolean | string => {
|
||||
if (v === "" || v === "false" || v === "0") return false
|
||||
if (v === "true" || v === "1") return true
|
||||
return v
|
||||
})
|
||||
|
||||
const csvUrls = z
|
||||
.string({ error: "obbligatoria" })
|
||||
.transform((v) =>
|
||||
v
|
||||
.split(",")
|
||||
.map((s) => s.trim())
|
||||
.filter(Boolean),
|
||||
)
|
||||
.pipe(z.array(z.url()).min(1, "almeno un'origine"))
|
||||
|
||||
const required = (min = 1) =>
|
||||
z.string({ error: "obbligatoria" }).trim().min(min, min > 1 ? `almeno ${min} caratteri` : "obbligatoria")
|
||||
|
||||
const postgresUrl = z
|
||||
.string({ error: "obbligatoria" })
|
||||
.trim()
|
||||
.regex(/^postgres(ql)?:\/\//, "deve essere un URL postgres://")
|
||||
|
||||
/** Variabili del database: usate dal server e dagli script db:migrate / db:seed. */
|
||||
export const dbEnvSchema = z.object({
|
||||
/** Postgres applicativo iti_app con l'utente iti_app (credenziali APP_DB_* del .env di root). */
|
||||
DATABASE_URL: postgresUrl,
|
||||
})
|
||||
|
||||
/** Variabili dello script db:seed (utente admin iniziale). */
|
||||
export const seedEnvSchema = dbEnvSchema.extend({
|
||||
SEED_ADMIN_EMAIL: z.string({ error: "obbligatoria" }).trim().toLowerCase().pipe(z.email("email non valida")),
|
||||
SEED_ADMIN_PASSWORD: required(12),
|
||||
SEED_ADMIN_FIRST_NAME: z.string().trim().default("Admin"),
|
||||
SEED_ADMIN_LAST_NAME: z.string().trim().default("ITI"),
|
||||
})
|
||||
|
||||
export const envSchema = dbEnvSchema
|
||||
.extend({
|
||||
NODE_ENV: z.enum(["development", "production", "test"]).default("development"),
|
||||
HOST: z.string().default("0.0.0.0"),
|
||||
PORT: z.coerce.number().int().min(1).max(65535).default(3000),
|
||||
LOG_LEVEL: z.enum(["fatal", "error", "warn", "info", "debug", "trace", "silent"]).default("info"),
|
||||
/** Cartella dei log su file (obbligatoria in produzione). */
|
||||
LOG_DIR: z.string().trim().min(1).optional(),
|
||||
/**
|
||||
* Dietro Caddy: IP client reale da X-Forwarded-For (rate limit, log). In produzione "loopback,uniquelocal":
|
||||
* fidarsi solo dei proxy sulla rete Docker (Caddy e il server SSR, che inoltra l'IP del visitatore).
|
||||
*/
|
||||
TRUST_PROXY: trustProxyFromEnv.default(false),
|
||||
/** Origini CORS consentite, separate da virgola (sito pubblico + area privata). */
|
||||
CORS_ORIGINS: csvUrls,
|
||||
/** Segreto per i cookie firmati. */
|
||||
COOKIE_SECRET: required(32),
|
||||
RATE_LIMIT_MAX: z.coerce.number().int().positive().default(300),
|
||||
RATE_LIMIT_WINDOW: z.string().default("1 minute"),
|
||||
|
||||
// --- Autenticazione (prompt 11) ---
|
||||
/** Segreto HS256 degli access token JWT. */
|
||||
JWT_SECRET: required(32),
|
||||
ACCESS_TOKEN_TTL_SECONDS: z.coerce.number().int().positive().default(900),
|
||||
REFRESH_TOKEN_TTL_DAYS: z.coerce.number().int().positive().default(30),
|
||||
/** Dominio padre del cookie di refresh (es. ".ititechnology.it") per condividerlo tra sottodomini; vuoto = host corrente. */
|
||||
AUTH_COOKIE_DOMAIN: z.string().trim().min(1).optional(),
|
||||
/** Secure sul cookie di refresh. I browser lo accettano anche su http://localhost. */
|
||||
AUTH_COOKIE_SECURE: booleanFromEnv.default(true),
|
||||
/** URL dell'area privata: base dei link nelle email (verifica email, reset password). */
|
||||
PRIVATE_APP_URL: z.url({ error: "obbligatoria, URL valido" }).transform((u) => u.replace(/\/+$/, "")),
|
||||
/** Limite per IP su login e reset password (richieste al minuto). */
|
||||
AUTH_LOGIN_RATE_MAX: z.coerce.number().int().positive().default(5),
|
||||
/** Limite per IP sulle route che inviano email (register, forgot-password, resend), ogni 15 minuti. */
|
||||
AUTH_EMAIL_RATE_MAX: z.coerce.number().int().positive().default(3),
|
||||
/** Versione dell'informativa privacy accettata nei form (salvata in consents.version). */
|
||||
PRIVACY_POLICY_VERSION: z.string().trim().min(1).default("privacy-v1"),
|
||||
/** Destinatari delle notifiche interne (es. nuovo ordine), separati da virgola. Se vuota: utenti admin attivi. */
|
||||
ADMIN_NOTIFY_EMAILS: z
|
||||
.string()
|
||||
.transform((v) => v.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean))
|
||||
.pipe(z.array(z.email("email non valida")))
|
||||
.optional(),
|
||||
// --- Storage (prompt 16) ---
|
||||
/** Radice dello storage su disco: contiene public/ (servita da Caddy) e private/ (solo via API). */
|
||||
UPLOAD_DIR: z.string().trim().min(1).default("uploads"),
|
||||
/** Dimensione massima di un file caricato (byte). Default 25 MB. */
|
||||
UPLOAD_MAX_BYTES: z.coerce.number().int().positive().default(25 * 1024 * 1024),
|
||||
/** URL base dell'area public (Caddy in produzione; in sviluppo la serve l'API su /uploads). */
|
||||
PUBLIC_UPLOADS_URL: z
|
||||
.url({ error: "deve essere un URL" })
|
||||
.default("http://localhost:3000/uploads")
|
||||
.transform((u) => u.replace(/\/+$/, "")),
|
||||
|
||||
// --- Email (prompt 15) ---
|
||||
/** Provider delle email transazionali: console (solo log/outbox), brevo, resend. */
|
||||
EMAIL_PROVIDER: z.enum(["console", "brevo", "resend"]).default("console"),
|
||||
/** Mittente (dominio verificato sul provider); obbligatorio con brevo/resend. */
|
||||
EMAIL_FROM: z.string().trim().toLowerCase().pipe(z.email("email non valida")).optional(),
|
||||
EMAIL_FROM_NAME: z.string().trim().min(1).default("ITI TECHNOLOGY"),
|
||||
/** Reply-To facoltativo (es. casella dell'assistenza). */
|
||||
EMAIL_REPLY_TO: z.string().trim().toLowerCase().pipe(z.email("email non valida")).optional(),
|
||||
BREVO_API_KEY: z.string().trim().min(1).optional(),
|
||||
RESEND_API_KEY: z.string().trim().min(1).optional(),
|
||||
/** Solo sviluppo: cartella in cui l'adapter email console salva i messaggi completi (con i link). */
|
||||
EMAIL_OUTBOX_DIR: z.string().trim().min(1).optional(),
|
||||
|
||||
// --- Medusa (prompt 04, 06, 07, 12) ---
|
||||
/** URL del backend Medusa raggiungibile da apps/api (in Docker: http://medusa:9000). */
|
||||
MEDUSA_URL: z.url({ error: "deve essere un URL" }).default("http://localhost:9000").transform((u) => u.replace(/\/+$/, "")),
|
||||
/** Timeout di ogni chiamata a Medusa (ms). */
|
||||
MEDUSA_TIMEOUT_MS: z.coerce.number().int().positive().default(5000),
|
||||
/** Payment provider Medusa usato al checkout (Stripe: pp_stripe_stripe). */
|
||||
MEDUSA_PAYMENT_PROVIDER_ID: z.string().trim().min(1).default("pp_stripe_stripe"),
|
||||
MEDUSA_PUBLISHABLE_KEY: required(),
|
||||
INTERNAL_API_SECRET: required(32),
|
||||
WEBHOOK_SECRET: required(32),
|
||||
})
|
||||
.superRefine((env, ctx) => {
|
||||
if (env.NODE_ENV === "production" && !env.LOG_DIR) {
|
||||
ctx.addIssue({ code: "custom", path: ["LOG_DIR"], message: "obbligatoria in produzione" })
|
||||
}
|
||||
if (env.NODE_ENV === "production" && !isAbsolute(env.UPLOAD_DIR)) {
|
||||
ctx.addIssue({ code: "custom", path: ["UPLOAD_DIR"], message: "in produzione deve essere un percorso assoluto" })
|
||||
}
|
||||
if (env.NODE_ENV === "production" && /\/\/(localhost|127\.0\.0\.1)[:/]/.test(`${env.PUBLIC_UPLOADS_URL}/`)) {
|
||||
ctx.addIssue({ code: "custom", path: ["PUBLIC_UPLOADS_URL"], message: "in produzione deve essere l'URL pubblico servito da Caddy" })
|
||||
}
|
||||
if (env.NODE_ENV === "production" && !env.AUTH_COOKIE_SECURE) {
|
||||
ctx.addIssue({ code: "custom", path: ["AUTH_COOKIE_SECURE"], message: "in produzione deve essere true (cookie solo su HTTPS)" })
|
||||
}
|
||||
if (env.NODE_ENV === "production" && env.CORS_ORIGINS.some((o) => !o.startsWith("https://"))) {
|
||||
ctx.addIssue({ code: "custom", path: ["CORS_ORIGINS"], message: "in produzione solo origini https://" })
|
||||
}
|
||||
if (env.NODE_ENV === "production" && env.EMAIL_OUTBOX_DIR) {
|
||||
// L'outbox salva le email complete, con i link di verifica e reset password (token validi)
|
||||
ctx.addIssue({ code: "custom", path: ["EMAIL_OUTBOX_DIR"], message: "non ammessa in produzione" })
|
||||
}
|
||||
if (env.EMAIL_PROVIDER !== "console" && !env.EMAIL_FROM) {
|
||||
ctx.addIssue({ code: "custom", path: ["EMAIL_FROM"], message: `obbligatoria con EMAIL_PROVIDER=${env.EMAIL_PROVIDER}` })
|
||||
}
|
||||
if (env.EMAIL_PROVIDER === "brevo" && !env.BREVO_API_KEY) {
|
||||
ctx.addIssue({ code: "custom", path: ["BREVO_API_KEY"], message: "obbligatoria con EMAIL_PROVIDER=brevo" })
|
||||
}
|
||||
if (env.EMAIL_PROVIDER === "resend" && !env.RESEND_API_KEY) {
|
||||
ctx.addIssue({ code: "custom", path: ["RESEND_API_KEY"], message: "obbligatoria con EMAIL_PROVIDER=resend" })
|
||||
}
|
||||
})
|
||||
|
||||
export type Config = z.infer<typeof envSchema>
|
||||
|
||||
export class ConfigError extends Error {
|
||||
constructor(readonly problems: string[]) {
|
||||
super(`Configurazione non valida:\n${problems.map((p) => ` - ${p}`).join("\n")}`)
|
||||
this.name = "ConfigError"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Carica apps/api/.env (se esiste, senza sovrascrivere variabili già impostate) e valida l'ambiente
|
||||
* con lo schema dato. Lancia ConfigError con l'elenco di tutte le variabili mancanti o non valide.
|
||||
*/
|
||||
export function parseEnv<S extends z.ZodType>(schema: S, env: NodeJS.ProcessEnv = process.env): z.infer<S> {
|
||||
if (env === process.env && existsSync(ENV_FILE)) {
|
||||
process.loadEnvFile(ENV_FILE)
|
||||
}
|
||||
|
||||
// `VAR=` vuota nel .env conta come non impostata (default o "obbligatoria")
|
||||
const defined = Object.fromEntries(Object.entries(env).filter(([, v]) => v !== undefined && v.trim() !== ""))
|
||||
const result = schema.safeParse(defined)
|
||||
if (!result.success) {
|
||||
throw new ConfigError(
|
||||
result.error.issues.map((issue) => `${issue.path.join(".") || "(env)"}: ${issue.message}`),
|
||||
)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
/** Configurazione completa del server. */
|
||||
export const loadConfig = (env: NodeJS.ProcessEnv = process.env): Config => parseEnv(envSchema, env)
|
||||
@@ -0,0 +1,57 @@
|
||||
import { join, resolve } from "node:path"
|
||||
import type { FastifyServerOptions } from "fastify"
|
||||
import type { Config } from "./env.js"
|
||||
|
||||
/** Header e campi mai scritti nei log. */
|
||||
const REDACT = [
|
||||
"req.headers.authorization",
|
||||
"req.headers.cookie",
|
||||
"req.headers['x-internal-secret']",
|
||||
"req.headers['x-signature']",
|
||||
"res.headers['set-cookie']",
|
||||
]
|
||||
|
||||
/**
|
||||
* Logger pino per ambiente:
|
||||
* - development: console leggibile (pino-pretty);
|
||||
* - production: JSON su stdout (log Docker) + file con rotazione in LOG_DIR
|
||||
* (pino-roll: un file al giorno o ogni 50 MB, si tengono gli ultimi 14);
|
||||
* - test: disattivato.
|
||||
*/
|
||||
export function buildLoggerOptions(config: Config): NonNullable<FastifyServerOptions["logger"]> {
|
||||
if (config.NODE_ENV === "test") return false
|
||||
|
||||
const base = { level: config.LOG_LEVEL, redact: { paths: REDACT, censor: "[redacted]" } }
|
||||
|
||||
if (config.NODE_ENV === "development") {
|
||||
return {
|
||||
...base,
|
||||
transport: {
|
||||
target: "pino-pretty",
|
||||
options: { translateTime: "SYS:HH:MM:ss.l", ignore: "pid,hostname" },
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
const logDir = resolve(config.LOG_DIR as string)
|
||||
return {
|
||||
...base,
|
||||
transport: {
|
||||
targets: [
|
||||
{ target: "pino/file", level: config.LOG_LEVEL, options: { destination: 1 } },
|
||||
{
|
||||
target: "pino-roll",
|
||||
level: config.LOG_LEVEL,
|
||||
options: {
|
||||
file: join(logDir, "api"),
|
||||
frequency: "daily",
|
||||
dateFormat: "yyyy-MM-dd",
|
||||
size: "50m",
|
||||
limit: { count: 14 },
|
||||
mkdir: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,224 @@
|
||||
import type { ConfiguratorDefinition } from "@iti/shared"
|
||||
import { PROVINCES } from "./rules.js"
|
||||
|
||||
/**
|
||||
* DEFINIZIONE DEL WIZARD — step, domande, opzioni e condizioni di visibilità (branching).
|
||||
*
|
||||
* - È servita così com'è da GET /configurator/definition: il frontend la rende senza logica propria.
|
||||
* - `visibleIf`: elenco di condizioni in AND su risposte di domande PRECEDENTI
|
||||
* (`in`: scelta singola tra i valori; `includes`: scelta multipla che contiene il valore).
|
||||
* Una domanda è visibile se sono visibili il suo step e lei stessa.
|
||||
* - Le risposte a domande non visibili vengono rifiutate (risposte incoerenti), quelle obbligatorie
|
||||
* e visibili sono richieste.
|
||||
* - Cambiando id o valori delle opzioni vanno aggiornati engine.ts (che li legge) e i test;
|
||||
* etichette e testi di aiuto si cambiano liberamente. Aggiornare DEFINITION_VERSION.
|
||||
*/
|
||||
export const DEFINITION_VERSION = "2026-10-01"
|
||||
|
||||
const path = (...values: string[]) => [{ question: "percorso", in: values }]
|
||||
const yesNo = [
|
||||
{ value: "si", label: "Sì" },
|
||||
{ value: "no", label: "No" },
|
||||
]
|
||||
|
||||
export const definition: ConfiguratorDefinition = {
|
||||
version: DEFINITION_VERSION,
|
||||
provinces: PROVINCES.map(({ code, name }) => ({ code, name })),
|
||||
steps: [
|
||||
{
|
||||
id: "esigenza",
|
||||
title: "Di cosa hai bisogno?",
|
||||
questions: [
|
||||
{
|
||||
id: "percorso",
|
||||
type: "single",
|
||||
label: "Scegli l'esigenza principale",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "acqua", label: "Acqua calcarea", help: "Calcare su rubinetti, elettrodomestici, caldaia" },
|
||||
{ value: "fotovoltaico", label: "Bolletta alta / fotovoltaico", help: "Produrre energia e ridurre la bolletta elettrica" },
|
||||
{ value: "pompa_calore", label: "Riscaldamento / pompa di calore", help: "Sostituire caldaia o stufe" },
|
||||
{ value: "azienda", label: "Sono un'azienda", help: "Soluzioni per imprese, commercio, agricoltura" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --- Acqua ---
|
||||
{
|
||||
id: "acqua",
|
||||
title: "La tua acqua",
|
||||
visibleIf: path("acqua"),
|
||||
questions: [
|
||||
{ id: "acqua_persone", type: "number", label: "Quante persone vivono in casa?", unit: "persone", min: 1, max: 12, step: 1, required: true },
|
||||
{ id: "acqua_durezza_nota", type: "single", label: "Conosci la durezza dell'acqua?", help: "La trovi nella bolletta dell'acqua o sul sito del gestore", required: true, options: yesNo },
|
||||
{
|
||||
id: "acqua_durezza",
|
||||
type: "number",
|
||||
label: "Durezza dell'acqua",
|
||||
unit: "°f",
|
||||
min: 5,
|
||||
max: 80,
|
||||
step: 1,
|
||||
required: true,
|
||||
visibleIf: [{ question: "acqua_durezza_nota", in: ["si"] }],
|
||||
},
|
||||
{
|
||||
id: "acqua_abitazione",
|
||||
type: "single",
|
||||
label: "Tipo di abitazione",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "appartamento", label: "Appartamento" },
|
||||
{ value: "casa", label: "Casa indipendente" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --- Luogo (fotovoltaico, pompa di calore, aziende) ---
|
||||
{
|
||||
id: "luogo",
|
||||
title: "Dove si trova l'immobile?",
|
||||
visibleIf: path("fotovoltaico", "pompa_calore", "azienda"),
|
||||
questions: [{ id: "provincia", type: "province", label: "Provincia", required: true }],
|
||||
},
|
||||
|
||||
// --- Fotovoltaico ---
|
||||
{
|
||||
id: "fotovoltaico",
|
||||
title: "I tuoi consumi elettrici",
|
||||
visibleIf: path("fotovoltaico"),
|
||||
questions: [
|
||||
{
|
||||
id: "fv_input",
|
||||
type: "single",
|
||||
label: "Come preferisci indicare i consumi?",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "kwh", label: "Consumo annuo in kWh", help: "Lo trovi nella bolletta elettrica" },
|
||||
{ value: "bolletta", label: "Spesa media mensile in bolletta" },
|
||||
],
|
||||
},
|
||||
{ id: "fv_consumo_kwh", type: "number", label: "Consumo annuo", unit: "kWh", min: 500, max: 30000, step: 1, required: true, visibleIf: [{ question: "fv_input", in: ["kwh"] }] },
|
||||
{ id: "fv_bolletta_mensile", type: "number", label: "Spesa media mensile", unit: "€", min: 20, max: 1000, step: 1, required: true, visibleIf: [{ question: "fv_input", in: ["bolletta"] }] },
|
||||
{
|
||||
id: "fv_orientamento",
|
||||
type: "single",
|
||||
label: "Orientamento del tetto",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "sud", label: "Sud" },
|
||||
{ value: "est_ovest", label: "Est-Ovest" },
|
||||
{ value: "altro", label: "Altro (es. nord)" },
|
||||
{ value: "non_so", label: "Non lo so" },
|
||||
],
|
||||
},
|
||||
{ id: "fv_superficie_tetto", type: "number", label: "Superficie del tetto disponibile (facoltativa)", unit: "m²", min: 10, max: 1000, step: 1, required: false },
|
||||
{ id: "fv_accumulo", type: "single", label: "Vuoi una batteria di accumulo?", help: "Usa di sera l'energia prodotta di giorno", required: true, options: yesNo },
|
||||
{
|
||||
id: "fv_uso_diurno",
|
||||
type: "single",
|
||||
label: "Quanto consumi durante il giorno?",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "alto", label: "Molto (casa abitata di giorno, smart working)" },
|
||||
{ value: "medio", label: "Nella media" },
|
||||
{ value: "basso", label: "Poco (casa vuota di giorno)" },
|
||||
],
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
// --- Pompa di calore ---
|
||||
{
|
||||
id: "pompa_calore",
|
||||
title: "La tua casa e il riscaldamento",
|
||||
visibleIf: path("pompa_calore"),
|
||||
questions: [
|
||||
{ id: "pdc_superficie", type: "number", label: "Superficie riscaldata", unit: "m²", min: 30, max: 500, step: 1, required: true },
|
||||
{
|
||||
id: "pdc_isolamento",
|
||||
type: "single",
|
||||
label: "Com'è isolata la casa?",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "nuovo", label: "Costruzione recente o classe A/B" },
|
||||
{ value: "ristrutturato", label: "Ristrutturata (cappotto o infissi nuovi)" },
|
||||
{ value: "vecchio", label: "Non isolata / costruzione datata" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pdc_combustibile",
|
||||
type: "single",
|
||||
label: "Con cosa ti riscaldi oggi?",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "metano", label: "Metano" },
|
||||
{ value: "gpl", label: "GPL" },
|
||||
{ value: "gasolio", label: "Gasolio" },
|
||||
{ value: "elettrico", label: "Elettrico (stufe, boiler)" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "pdc_terminali",
|
||||
type: "single",
|
||||
label: "Impianto di riscaldamento",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "pavimento", label: "A pavimento" },
|
||||
{ value: "fancoil", label: "Ventilconvettori (fancoil)" },
|
||||
{ value: "radiatori", label: "Termosifoni" },
|
||||
],
|
||||
},
|
||||
{ id: "pdc_acs", type: "single", label: "Anche per l'acqua calda sanitaria?", required: true, options: yesNo },
|
||||
],
|
||||
},
|
||||
|
||||
// --- Aziende ---
|
||||
{
|
||||
id: "azienda",
|
||||
title: "La tua azienda",
|
||||
visibleIf: path("azienda"),
|
||||
questions: [
|
||||
{
|
||||
id: "az_settore",
|
||||
type: "single",
|
||||
label: "Settore",
|
||||
required: true,
|
||||
options: [
|
||||
{ value: "industria", label: "Industria / produzione" },
|
||||
{ value: "commercio", label: "Commercio / uffici" },
|
||||
{ value: "agricoltura", label: "Agricoltura / allevamento" },
|
||||
{ value: "ricettivo", label: "Turismo / ristorazione" },
|
||||
{ value: "altro", label: "Altro" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "az_interessi",
|
||||
type: "multi",
|
||||
label: "Cosa ti interessa?",
|
||||
required: true,
|
||||
minSelected: 1,
|
||||
options: [
|
||||
{ value: "fotovoltaico", label: "Fotovoltaico" },
|
||||
{ value: "acqua", label: "Trattamento acqua" },
|
||||
{ value: "termico", label: "Riscaldamento e raffrescamento" },
|
||||
{ value: "mobilita", label: "Ricarica veicoli elettrici" },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: "az_consumo_kwh",
|
||||
type: "number",
|
||||
label: "Consumo elettrico annuo",
|
||||
unit: "kWh",
|
||||
min: 5000,
|
||||
max: 5_000_000,
|
||||
step: 1,
|
||||
required: true,
|
||||
visibleIf: [{ question: "az_interessi", includes: "fotovoltaico" }],
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,400 @@
|
||||
import type {
|
||||
ConfiguratorAnswers,
|
||||
ConfiguratorCondition,
|
||||
ConfiguratorDefinition,
|
||||
ConfiguratorPath,
|
||||
ConfiguratorQuestion,
|
||||
ConfiguratorResult,
|
||||
} from "@iti/shared"
|
||||
import * as R from "./rules.js"
|
||||
|
||||
/**
|
||||
* Motore del configuratore: funzioni PURE (nessun accesso a DB o rete), input → output.
|
||||
* - validateAnswers: controlla le risposte rispetto alla definizione (tipi, opzioni, range,
|
||||
* obbligatorietà, coerenza con il branching).
|
||||
* - evaluate: calcola la raccomandazione dalle risposte validate, usando solo rules.ts.
|
||||
*/
|
||||
|
||||
export type AnswerError = { path: string; message: string }
|
||||
export type ValidationResult = { ok: true; answers: ConfiguratorAnswers } | { ok: false; errors: AnswerError[] }
|
||||
|
||||
function conditionsHold(conditions: ConfiguratorCondition[] | undefined, answers: ConfiguratorAnswers): boolean {
|
||||
return (conditions ?? []).every((c) => {
|
||||
const value = answers[c.question]
|
||||
if (c.in) return typeof value === "string" && c.in.includes(value)
|
||||
if (c.includes) return Array.isArray(value) && value.includes(c.includes)
|
||||
return true
|
||||
})
|
||||
}
|
||||
|
||||
function checkValue(q: ConfiguratorQuestion, value: unknown, provinces: Set<string>): string | null {
|
||||
switch (q.type) {
|
||||
case "single":
|
||||
return typeof value === "string" && q.options.some((o) => o.value === value) ? null : "Opzione non valida"
|
||||
case "multi": {
|
||||
if (!Array.isArray(value) || !value.every((v) => typeof v === "string")) return "Elenco di opzioni atteso"
|
||||
if (new Set(value).size !== value.length) return "Opzioni ripetute"
|
||||
if (!value.every((v) => q.options.some((o) => o.value === v))) return "Opzione non valida"
|
||||
return value.length >= q.minSelected ? null : `Seleziona almeno ${q.minSelected} opzione`
|
||||
}
|
||||
case "number":
|
||||
if (typeof value !== "number" || !Number.isFinite(value)) return "Numero atteso"
|
||||
if (value < q.min || value > q.max) return `Valore tra ${q.min} e ${q.max} ${q.unit}`
|
||||
return Number.isInteger(value / q.step) || q.step === 0 ? null : `Valore a passi di ${q.step}`
|
||||
case "province":
|
||||
return typeof value === "string" && provinces.has(value) ? null : "Provincia non valida"
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Valida le risposte: domande inesistenti, risposte a domande non visibili per le scelte fatte
|
||||
* (incoerenti), obbligatorie mancanti, tipi/opzioni/range non validi → errori con path "answers.<id>".
|
||||
* Le condizioni sono valutate nell'ordine della definizione, solo su risposte già validate.
|
||||
*/
|
||||
export function validateAnswers(def: ConfiguratorDefinition, raw: Record<string, unknown>): ValidationResult {
|
||||
const provinces = new Set(def.provinces.map((p) => p.code))
|
||||
const known = new Set(def.steps.flatMap((s) => s.questions.map((q) => q.id)))
|
||||
const errors: AnswerError[] = []
|
||||
const answers: ConfiguratorAnswers = {}
|
||||
|
||||
for (const id of Object.keys(raw)) {
|
||||
if (!known.has(id)) errors.push({ path: `answers.${id}`, message: "Domanda inesistente" })
|
||||
}
|
||||
|
||||
for (const step of def.steps) {
|
||||
const stepVisible = conditionsHold(step.visibleIf, answers)
|
||||
for (const q of step.questions) {
|
||||
const visible = stepVisible && conditionsHold(q.visibleIf, answers)
|
||||
const value = raw[q.id]
|
||||
const present = value !== undefined && value !== null && value !== ""
|
||||
if (!visible) {
|
||||
if (present) errors.push({ path: `answers.${q.id}`, message: "Risposta non prevista per le scelte fatte" })
|
||||
continue
|
||||
}
|
||||
if (!present) {
|
||||
if (q.required) errors.push({ path: `answers.${q.id}`, message: "Risposta obbligatoria" })
|
||||
continue
|
||||
}
|
||||
const problem = checkValue(q, value, provinces)
|
||||
if (problem) errors.push({ path: `answers.${q.id}`, message: problem })
|
||||
else answers[q.id] = value as string | number | string[]
|
||||
}
|
||||
}
|
||||
return errors.length ? { ok: false, errors } : { ok: true, answers }
|
||||
}
|
||||
|
||||
// --- Utilità di calcolo ---
|
||||
|
||||
const eurToCents = (eur: number) => Math.round(eur * 100)
|
||||
/** Investimento arrotondato alle centinaia di euro (è una stima). */
|
||||
const roundInvestment = (eur: number) => Math.round(eur / 100) * 100
|
||||
const ceilTo = (value: number, step: number) => Math.ceil(value / step - 1e-9) * step
|
||||
const floorTo = (value: number, step: number) => Math.floor(value / step + 1e-9) * step
|
||||
const clamp = (v: number, min: number, max: number) => Math.min(Math.max(v, min), max)
|
||||
const round1 = (v: number) => Math.round(v * 10) / 10
|
||||
/**
|
||||
* Numeri in italiano con separatore delle migliaia anche sotto le 5 cifre ("4.025"): per it-IT il
|
||||
* CLDR lo omette; "always" è ES2023 (Node 24), i tipi della lib ES2022 ammettono solo boolean.
|
||||
*/
|
||||
const ALWAYS = "always" as unknown as boolean
|
||||
const fmt = (v: number, digits = 0) => new Intl.NumberFormat("it-IT", { maximumFractionDigits: digits, useGrouping: ALWAYS }).format(v)
|
||||
const fmtEur = (eur: number) =>
|
||||
new Intl.NumberFormat("it-IT", { style: "currency", currency: "EUR", maximumFractionDigits: 0, useGrouping: ALWAYS }).format(eur)
|
||||
|
||||
const payback = (investmentEur: number, savingsEur: number) => (savingsEur > 0 ? round1(investmentEur / savingsEur) : null)
|
||||
|
||||
export function provinceZone(code: string): R.Zone {
|
||||
const p = R.PROVINCES.find((x) => x.code === code)
|
||||
if (!p) throw new Error(`Provincia sconosciuta: ${code}`)
|
||||
return p.zone
|
||||
}
|
||||
|
||||
const num = (a: ConfiguratorAnswers, id: string) => a[id] as number
|
||||
const str = (a: ConfiguratorAnswers, id: string) => a[id] as string
|
||||
|
||||
function base(path: ConfiguratorPath): Pick<ConfiguratorResult, "rulesVersion" | "path" | "incentivesNote" | "disclaimer"> {
|
||||
return { rulesVersion: R.RULES_VERSION, path, incentivesNote: R.INCENTIVES_NOTE[path], disclaimer: R.DISCLAIMER }
|
||||
}
|
||||
|
||||
// --- Percorsi ---
|
||||
|
||||
export function evaluateWater(a: ConfiguratorAnswers): ConfiguratorResult {
|
||||
const persons = num(a, "acqua_persone")
|
||||
const known = str(a, "acqua_durezza_nota") === "si"
|
||||
const hardness = known ? num(a, "acqua_durezza") : R.WATER.defaultHardnessF
|
||||
const warnings: string[] = []
|
||||
if (!known) warnings.push(`Durezza non indicata: ipotizzati ${R.WATER.defaultHardnessF} °f. La misuriamo gratuitamente in fase di sopralluogo.`)
|
||||
if (str(a, "acqua_abitazione") === "appartamento") {
|
||||
warnings.push("In appartamento serve uno spazio vicino all'ingresso dell'acqua e uno scarico: lo verifichiamo con il tecnico.")
|
||||
}
|
||||
|
||||
const details = [
|
||||
{ label: "Persone", value: fmt(persons) },
|
||||
{ label: "Durezza", value: `${fmt(hardness)} °f${known ? "" : " (stimata)"}` },
|
||||
]
|
||||
|
||||
if (hardness <= R.WATER.targetHardnessF) {
|
||||
return {
|
||||
...base("acqua"),
|
||||
solution: {
|
||||
code: "filtrazione",
|
||||
title: "Filtrazione, addolcitore non necessario",
|
||||
description: `Con una durezza di ${fmt(hardness)} °f l'addolcitore non serve: consigliamo un filtro per sedimenti a protezione dell'impianto.`,
|
||||
},
|
||||
size: null,
|
||||
products: [],
|
||||
estimates: { investmentCents: null, annualSavingsCents: null, paybackYears: null },
|
||||
details,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
const dailyLoad = (persons * R.WATER.litersPerPersonDay) / 1000 * (hardness - R.WATER.targetHardnessF)
|
||||
const resinLiters = (dailyLoad * R.WATER.regenerationDays) / R.WATER.resinCapacityFM3PerLiter
|
||||
const size = R.SOFTENER_SIZES.find((s) => s.liters >= resinLiters)
|
||||
details.push({ label: "Resina necessaria", value: `${fmt(resinLiters, 1)} litri` })
|
||||
|
||||
if (!size) {
|
||||
return {
|
||||
...base("acqua"),
|
||||
solution: {
|
||||
code: "addolcitore_sopralluogo",
|
||||
title: "Addolcitore di grande capacità",
|
||||
description: "Il fabbisogno supera le taglie domestiche standard: dimensioniamo l'impianto con un sopralluogo.",
|
||||
},
|
||||
size: { value: round1(resinLiters), unit: "L", label: `Oltre ${fmt(R.SOFTENER_SIZES.at(-1)!.liters)} litri di resina` },
|
||||
products: [],
|
||||
estimates: { investmentCents: null, annualSavingsCents: null, paybackYears: null },
|
||||
details,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
const investment = roundInvestment(size.costEur + R.SOFTENER_INSTALLATION_EUR)
|
||||
const savings =
|
||||
persons *
|
||||
R.WATER.savingsEurPerPersonAt40F *
|
||||
Math.min((hardness - R.WATER.targetHardnessF) / (40 - R.WATER.targetHardnessF), 1.5)
|
||||
details.push({ label: "Risparmio stimato", value: `${fmtEur(savings)} all'anno (calcare, energia, detersivi)` })
|
||||
|
||||
return {
|
||||
...base("acqua"),
|
||||
solution: {
|
||||
code: "addolcitore",
|
||||
title: `Addolcitore ${size.liters} litri`,
|
||||
description: `Addolcitore a resine da ${size.liters} litri con rigenerazione ogni ${R.WATER.regenerationDays} giorni circa, durezza in uscita ${R.WATER.targetHardnessF} °f.`,
|
||||
},
|
||||
size: { value: size.liters, unit: "L", label: `${size.liters} litri di resina` },
|
||||
products: size.handle ? [{ handle: size.handle, quantity: 1, note: null }] : [],
|
||||
estimates: {
|
||||
investmentCents: eurToCents(investment),
|
||||
annualSavingsCents: eurToCents(Math.round(savings)),
|
||||
paybackYears: payback(investment, savings),
|
||||
},
|
||||
details,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
/** Dimensionamento e stima fotovoltaico residenziale. */
|
||||
export function evaluatePv(a: ConfiguratorAnswers): ConfiguratorResult {
|
||||
const zone = provinceZone(str(a, "provincia"))
|
||||
const consumption =
|
||||
str(a, "fv_input") === "kwh"
|
||||
? num(a, "fv_consumo_kwh")
|
||||
: Math.round((num(a, "fv_bolletta_mensile") * 12) / R.ELECTRICITY_PRICE_EUR_KWH)
|
||||
const orientation = str(a, "fv_orientamento") as keyof typeof R.PV_ORIENTATION_FACTOR
|
||||
const yieldPerKwp = R.PV_YIELD_KWH_PER_KWP[zone] * R.PV_ORIENTATION_FACTOR[orientation]
|
||||
const warnings: string[] = []
|
||||
|
||||
const rawKwp = (consumption * R.PV_SIZING.coverage) / yieldPerKwp
|
||||
let kwp = clamp(ceilTo(rawKwp, R.PV_SIZING.roundToKwp), R.PV_SIZING.minKwp, R.PV_SIZING.maxKwp)
|
||||
if (rawKwp > R.PV_SIZING.maxKwp) warnings.push("Consumi elevati: un impianto più grande va valutato con un sopralluogo.")
|
||||
if (orientation === "altro") warnings.push("Orientamento poco favorevole: la produzione stimata è ridotta.")
|
||||
if (orientation === "non_so") warnings.push("Orientamento non indicato: stimato un valore medio.")
|
||||
|
||||
const roof = a.fv_superficie_tetto as number | undefined
|
||||
if (roof !== undefined) {
|
||||
const maxByRoof = floorTo(roof / R.PV_SIZING.roofM2PerKwp, R.PV_SIZING.roundToKwp)
|
||||
if (maxByRoof < kwp) {
|
||||
kwp = maxByRoof
|
||||
warnings.push(`La superficie indicata (${fmt(roof)} m²) limita l'impianto a ${fmt(kwp, 1)} kWp.`)
|
||||
}
|
||||
}
|
||||
|
||||
const production = kwp * yieldPerKwp
|
||||
const battery =
|
||||
str(a, "fv_accumulo") === "si"
|
||||
? (R.BATTERY_SIZING.sizesKwh.find((s) => s >= (consumption / 365) * R.BATTERY_SIZING.dailyFactor) ??
|
||||
R.BATTERY_SIZING.sizesKwh.at(-1)!)
|
||||
: 0
|
||||
const usage = str(a, "fv_uso_diurno") as "alto" | "medio" | "basso"
|
||||
const selfShare = Math.min(R.PV_SELF_CONSUMPTION[usage] + (battery ? R.PV_SELF_CONSUMPTION.batteryBonus : 0), R.PV_SELF_CONSUMPTION.max)
|
||||
const selfConsumed = Math.min(production * selfShare, consumption)
|
||||
const exported = production - selfConsumed
|
||||
const savings = selfConsumed * R.ELECTRICITY_PRICE_EUR_KWH + exported * R.GRID_SELL_PRICE_EUR_KWH
|
||||
const eurPerKwp = R.PV_COST_EUR_PER_KWP.find((b) => kwp <= b.maxKwp)!.eurPerKwp
|
||||
const investment = roundInvestment(kwp * eurPerKwp + battery * R.BATTERY_COST_EUR_PER_KWH)
|
||||
|
||||
return {
|
||||
...base("fotovoltaico"),
|
||||
solution: {
|
||||
code: battery ? "fotovoltaico_accumulo" : "fotovoltaico",
|
||||
title: battery ? `Fotovoltaico ${fmt(kwp, 1)} kWp con accumulo ${battery} kWh` : `Fotovoltaico ${fmt(kwp, 1)} kWp`,
|
||||
description: `Impianto da ${fmt(kwp, 1)} kWp che produce circa ${fmt(production)} kWh all'anno, il ${fmt(selfShare * 100)}% autoconsumato.`,
|
||||
},
|
||||
size: { value: kwp, unit: "kWp", label: `${fmt(kwp, 1)} kWp` },
|
||||
products: R.PV_PRODUCTS.filter((p) => kwp >= p.minKwp && kwp <= p.maxKwp && p.battery === (battery > 0)).map((p) => ({
|
||||
handle: p.handle,
|
||||
quantity: 1,
|
||||
note: p.note,
|
||||
})),
|
||||
estimates: {
|
||||
investmentCents: eurToCents(investment),
|
||||
annualSavingsCents: eurToCents(Math.round(savings)),
|
||||
paybackYears: payback(investment, savings),
|
||||
},
|
||||
details: [
|
||||
{ label: "Consumo annuo", value: `${fmt(consumption)} kWh${str(a, "fv_input") === "bolletta" ? " (stimato dalla bolletta)" : ""}` },
|
||||
{ label: "Produttività", value: `${fmt(yieldPerKwp)} kWh/kWp (zona ${zone})` },
|
||||
{ label: "Produzione annua", value: `${fmt(production)} kWh` },
|
||||
{ label: "Autoconsumo", value: `${fmt(selfShare * 100)}%` },
|
||||
...(battery ? [{ label: "Accumulo", value: `${battery} kWh` }] : []),
|
||||
],
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
export function evaluateHeatPump(a: ConfiguratorAnswers): ConfiguratorResult {
|
||||
const zone = provinceZone(str(a, "provincia"))
|
||||
const factor = R.CLIMATE_FACTOR[zone]
|
||||
const m2 = num(a, "pdc_superficie")
|
||||
const insulation = str(a, "pdc_isolamento") as R.Insulation
|
||||
const fuel = R.FUELS[str(a, "pdc_combustibile") as keyof typeof R.FUELS]
|
||||
const terminals = str(a, "pdc_terminali") as keyof typeof R.HEAT_PUMP_SCOP
|
||||
const dhw = str(a, "pdc_acs") === "si"
|
||||
const warnings: string[] = []
|
||||
|
||||
const kwNeeded = (m2 * R.HEATING_BY_INSULATION[insulation].wattPerM2 * factor) / 1000 + (dhw ? R.DHW.extraKw : 0)
|
||||
const demand = m2 * R.HEATING_BY_INSULATION[insulation].kwhPerM2Year * factor + (dhw ? R.DHW.kwhPerYear : 0)
|
||||
const scop = R.HEAT_PUMP_SCOP[terminals]
|
||||
const currentCost = (demand / (fuel.kwhPerUnit * fuel.efficiency)) * fuel.eurPerUnit
|
||||
const heatPumpCost = (demand / scop) * R.ELECTRICITY_PRICE_EUR_KWH
|
||||
const savings = currentCost - heatPumpCost
|
||||
const size = R.HEAT_PUMP_SIZES.find((s) => s.kw >= kwNeeded)
|
||||
|
||||
if (insulation === "vecchio" && terminals === "radiatori") {
|
||||
warnings.push("Con termosifoni in un edificio poco isolato conviene valutare isolamento o un sistema ibrido.")
|
||||
}
|
||||
if (savings <= 0) warnings.push("Con i prezzi attuali il risparmio sui costi di esercizio è minimo: valutiamo insieme la soluzione.")
|
||||
|
||||
const details = [
|
||||
{ label: "Potenza necessaria", value: `${fmt(kwNeeded, 1)} kW` },
|
||||
{ label: "Fabbisogno annuo", value: `${fmt(demand)} kWh termici` },
|
||||
{ label: `Costo attuale (${fuel.label})`, value: `${fmtEur(currentCost)} all'anno` },
|
||||
{ label: "Costo con pompa di calore", value: `${fmtEur(heatPumpCost)} all'anno (SCOP ${fmt(scop, 1)})` },
|
||||
]
|
||||
|
||||
if (!size) {
|
||||
return {
|
||||
...base("pompa_calore"),
|
||||
solution: {
|
||||
code: "pompa_calore_sopralluogo",
|
||||
title: "Pompa di calore di grande potenza",
|
||||
description: "La potenza necessaria supera le taglie domestiche standard: la dimensioniamo con un sopralluogo.",
|
||||
},
|
||||
size: { value: round1(kwNeeded), unit: "kW", label: `${fmt(kwNeeded, 1)} kW` },
|
||||
products: [],
|
||||
estimates: { investmentCents: null, annualSavingsCents: savings > 0 ? eurToCents(Math.round(savings)) : 0, paybackYears: null },
|
||||
details,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
const investment = roundInvestment(size.costEur)
|
||||
return {
|
||||
...base("pompa_calore"),
|
||||
solution: {
|
||||
code: "pompa_calore",
|
||||
title: `Pompa di calore aria-acqua ${size.kw} kW`,
|
||||
description: `Pompa di calore da ${size.kw} kW per riscaldamento${dhw ? " e acqua calda sanitaria" : ""}, al posto dell'impianto a ${fuel.label.toLowerCase()}.`,
|
||||
},
|
||||
size: { value: size.kw, unit: "kW", label: `${size.kw} kW` },
|
||||
products: size.handle ? [{ handle: size.handle, quantity: 1, note: null }] : [],
|
||||
estimates: {
|
||||
investmentCents: eurToCents(investment),
|
||||
annualSavingsCents: eurToCents(Math.max(Math.round(savings), 0)),
|
||||
paybackYears: payback(investment, savings),
|
||||
},
|
||||
details,
|
||||
warnings,
|
||||
}
|
||||
}
|
||||
|
||||
const BUSINESS_INTERESTS: Record<string, string> = {
|
||||
fotovoltaico: "fotovoltaico",
|
||||
acqua: "trattamento acqua",
|
||||
termico: "riscaldamento e raffrescamento",
|
||||
mobilita: "ricarica veicoli elettrici",
|
||||
}
|
||||
|
||||
export function evaluateBusiness(a: ConfiguratorAnswers): ConfiguratorResult {
|
||||
const interests = a.az_interessi as string[]
|
||||
const zone = provinceZone(str(a, "provincia"))
|
||||
const interestText = interests.map((i) => BUSINESS_INTERESTS[i] ?? i).join(", ")
|
||||
const details = [
|
||||
{ label: "Settore", value: str(a, "az_settore") },
|
||||
{ label: "Interessi", value: interestText },
|
||||
]
|
||||
const common = {
|
||||
...base("azienda"),
|
||||
solution: {
|
||||
code: "consulenza_aziendale",
|
||||
title: "Consulenza tecnica dedicata",
|
||||
description: `Per un'azienda progettiamo la soluzione dopo un sopralluogo. Ambiti indicati: ${interestText}.`,
|
||||
},
|
||||
products: [],
|
||||
warnings: [],
|
||||
}
|
||||
|
||||
if (!interests.includes("fotovoltaico")) {
|
||||
return { ...common, size: null, estimates: { investmentCents: null, annualSavingsCents: null, paybackYears: null }, details }
|
||||
}
|
||||
|
||||
const consumption = num(a, "az_consumo_kwh")
|
||||
const yieldPerKwp = R.PV_YIELD_KWH_PER_KWP[zone]
|
||||
const kwp = clamp(ceilTo((consumption * R.BUSINESS_PV.coverage) / yieldPerKwp, 1), R.BUSINESS_PV.minKwp, R.BUSINESS_PV.maxKwp)
|
||||
const production = kwp * yieldPerKwp
|
||||
const selfConsumed = Math.min(production * R.BUSINESS_PV.selfConsumption, consumption)
|
||||
const savings = selfConsumed * R.ELECTRICITY_PRICE_EUR_KWH + (production - selfConsumed) * R.GRID_SELL_PRICE_EUR_KWH
|
||||
const eurPerKwp = R.PV_COST_EUR_PER_KWP.find((b) => kwp <= b.maxKwp)!.eurPerKwp
|
||||
const investment = roundInvestment(kwp * eurPerKwp)
|
||||
details.push(
|
||||
{ label: "Consumo annuo", value: `${fmt(consumption)} kWh` },
|
||||
{ label: "Fotovoltaico indicativo", value: `${fmt(kwp)} kWp, circa ${fmt(production)} kWh/anno` },
|
||||
)
|
||||
return {
|
||||
...common,
|
||||
size: { value: kwp, unit: "kWp", label: `${fmt(kwp)} kWp (fotovoltaico indicativo)` },
|
||||
estimates: {
|
||||
investmentCents: eurToCents(investment),
|
||||
annualSavingsCents: eurToCents(Math.round(savings)),
|
||||
paybackYears: payback(investment, savings),
|
||||
},
|
||||
details,
|
||||
}
|
||||
}
|
||||
|
||||
/** Raccomandazione per risposte già validate con validateAnswers. */
|
||||
export function evaluate(answers: ConfiguratorAnswers): ConfiguratorResult {
|
||||
switch (answers.percorso as ConfiguratorPath) {
|
||||
case "acqua":
|
||||
return evaluateWater(answers)
|
||||
case "fotovoltaico":
|
||||
return evaluatePv(answers)
|
||||
case "pompa_calore":
|
||||
return evaluateHeatPump(answers)
|
||||
case "azienda":
|
||||
return evaluateBusiness(answers)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,276 @@
|
||||
/**
|
||||
* REGOLE DEL CONFIGURATORE — unico file da modificare per cambiare soglie, tabelle e prezzi.
|
||||
*
|
||||
* - Valori medi indicativi (IVA inclusa, euro) usati per stime, non per offerte.
|
||||
* - Dopo una modifica: aggiornare RULES_VERSION (salvata in ogni richiesta, per sapere con quali
|
||||
* regole è stata calcolata) e lanciare i test (`npm run test -w apps/api -- configurator`):
|
||||
* i casi tabellari in test/configurator.test.ts vanno riallineati ai nuovi valori.
|
||||
* - Le tabelle "a scaglioni" vanno tenute in ordine crescente.
|
||||
*/
|
||||
|
||||
export const RULES_VERSION = "2026-10-01"
|
||||
|
||||
// --- Energia ---
|
||||
|
||||
/** Prezzo medio dell'energia elettrica prelevata (€/kWh, tutto incluso). */
|
||||
export const ELECTRICITY_PRICE_EUR_KWH = 0.3
|
||||
/** Valorizzazione dell'energia immessa in rete (ritiro dedicato, €/kWh). */
|
||||
export const GRID_SELL_PRICE_EUR_KWH = 0.1
|
||||
|
||||
// --- Zone climatiche / produttività ---
|
||||
|
||||
export type Zone = "nord" | "centro" | "sud" | "isole"
|
||||
|
||||
/** Produttività media di un impianto fotovoltaico ben orientato (kWh prodotti per kWp all'anno). */
|
||||
export const PV_YIELD_KWH_PER_KWP: Record<Zone, number> = {
|
||||
nord: 1150,
|
||||
centro: 1300,
|
||||
sud: 1450,
|
||||
isole: 1500,
|
||||
}
|
||||
|
||||
/** Province (sigla → nome, zona). La zona determina produttività FV e fabbisogno di riscaldamento. */
|
||||
export const PROVINCES: { code: string; name: string; zone: Zone }[] = [
|
||||
// Piemonte
|
||||
{ code: "AL", name: "Alessandria", zone: "nord" },
|
||||
{ code: "AT", name: "Asti", zone: "nord" },
|
||||
{ code: "BI", name: "Biella", zone: "nord" },
|
||||
{ code: "CN", name: "Cuneo", zone: "nord" },
|
||||
{ code: "NO", name: "Novara", zone: "nord" },
|
||||
{ code: "TO", name: "Torino", zone: "nord" },
|
||||
{ code: "VB", name: "Verbano-Cusio-Ossola", zone: "nord" },
|
||||
{ code: "VC", name: "Vercelli", zone: "nord" },
|
||||
// Valle d'Aosta
|
||||
{ code: "AO", name: "Aosta", zone: "nord" },
|
||||
// Lombardia
|
||||
{ code: "BG", name: "Bergamo", zone: "nord" },
|
||||
{ code: "BS", name: "Brescia", zone: "nord" },
|
||||
{ code: "CO", name: "Como", zone: "nord" },
|
||||
{ code: "CR", name: "Cremona", zone: "nord" },
|
||||
{ code: "LC", name: "Lecco", zone: "nord" },
|
||||
{ code: "LO", name: "Lodi", zone: "nord" },
|
||||
{ code: "MN", name: "Mantova", zone: "nord" },
|
||||
{ code: "MI", name: "Milano", zone: "nord" },
|
||||
{ code: "MB", name: "Monza e Brianza", zone: "nord" },
|
||||
{ code: "PV", name: "Pavia", zone: "nord" },
|
||||
{ code: "SO", name: "Sondrio", zone: "nord" },
|
||||
{ code: "VA", name: "Varese", zone: "nord" },
|
||||
// Trentino-Alto Adige
|
||||
{ code: "BZ", name: "Bolzano", zone: "nord" },
|
||||
{ code: "TN", name: "Trento", zone: "nord" },
|
||||
// Veneto
|
||||
{ code: "BL", name: "Belluno", zone: "nord" },
|
||||
{ code: "PD", name: "Padova", zone: "nord" },
|
||||
{ code: "RO", name: "Rovigo", zone: "nord" },
|
||||
{ code: "TV", name: "Treviso", zone: "nord" },
|
||||
{ code: "VE", name: "Venezia", zone: "nord" },
|
||||
{ code: "VR", name: "Verona", zone: "nord" },
|
||||
{ code: "VI", name: "Vicenza", zone: "nord" },
|
||||
// Friuli-Venezia Giulia
|
||||
{ code: "GO", name: "Gorizia", zone: "nord" },
|
||||
{ code: "PN", name: "Pordenone", zone: "nord" },
|
||||
{ code: "TS", name: "Trieste", zone: "nord" },
|
||||
{ code: "UD", name: "Udine", zone: "nord" },
|
||||
// Liguria
|
||||
{ code: "GE", name: "Genova", zone: "nord" },
|
||||
{ code: "IM", name: "Imperia", zone: "nord" },
|
||||
{ code: "SP", name: "La Spezia", zone: "nord" },
|
||||
{ code: "SV", name: "Savona", zone: "nord" },
|
||||
// Emilia-Romagna
|
||||
{ code: "BO", name: "Bologna", zone: "nord" },
|
||||
{ code: "FE", name: "Ferrara", zone: "nord" },
|
||||
{ code: "FC", name: "Forlì-Cesena", zone: "nord" },
|
||||
{ code: "MO", name: "Modena", zone: "nord" },
|
||||
{ code: "PR", name: "Parma", zone: "nord" },
|
||||
{ code: "PC", name: "Piacenza", zone: "nord" },
|
||||
{ code: "RA", name: "Ravenna", zone: "nord" },
|
||||
{ code: "RE", name: "Reggio Emilia", zone: "nord" },
|
||||
{ code: "RN", name: "Rimini", zone: "nord" },
|
||||
// Toscana
|
||||
{ code: "AR", name: "Arezzo", zone: "centro" },
|
||||
{ code: "FI", name: "Firenze", zone: "centro" },
|
||||
{ code: "GR", name: "Grosseto", zone: "centro" },
|
||||
{ code: "LI", name: "Livorno", zone: "centro" },
|
||||
{ code: "LU", name: "Lucca", zone: "centro" },
|
||||
{ code: "MS", name: "Massa-Carrara", zone: "centro" },
|
||||
{ code: "PI", name: "Pisa", zone: "centro" },
|
||||
{ code: "PT", name: "Pistoia", zone: "centro" },
|
||||
{ code: "PO", name: "Prato", zone: "centro" },
|
||||
{ code: "SI", name: "Siena", zone: "centro" },
|
||||
// Umbria
|
||||
{ code: "PG", name: "Perugia", zone: "centro" },
|
||||
{ code: "TR", name: "Terni", zone: "centro" },
|
||||
// Marche
|
||||
{ code: "AN", name: "Ancona", zone: "centro" },
|
||||
{ code: "AP", name: "Ascoli Piceno", zone: "centro" },
|
||||
{ code: "FM", name: "Fermo", zone: "centro" },
|
||||
{ code: "MC", name: "Macerata", zone: "centro" },
|
||||
{ code: "PU", name: "Pesaro e Urbino", zone: "centro" },
|
||||
// Lazio
|
||||
{ code: "FR", name: "Frosinone", zone: "centro" },
|
||||
{ code: "LT", name: "Latina", zone: "centro" },
|
||||
{ code: "RI", name: "Rieti", zone: "centro" },
|
||||
{ code: "RM", name: "Roma", zone: "centro" },
|
||||
{ code: "VT", name: "Viterbo", zone: "centro" },
|
||||
// Abruzzo
|
||||
{ code: "AQ", name: "L'Aquila", zone: "sud" },
|
||||
{ code: "CH", name: "Chieti", zone: "sud" },
|
||||
{ code: "PE", name: "Pescara", zone: "sud" },
|
||||
{ code: "TE", name: "Teramo", zone: "sud" },
|
||||
// Molise
|
||||
{ code: "CB", name: "Campobasso", zone: "sud" },
|
||||
{ code: "IS", name: "Isernia", zone: "sud" },
|
||||
// Campania
|
||||
{ code: "AV", name: "Avellino", zone: "sud" },
|
||||
{ code: "BN", name: "Benevento", zone: "sud" },
|
||||
{ code: "CE", name: "Caserta", zone: "sud" },
|
||||
{ code: "NA", name: "Napoli", zone: "sud" },
|
||||
{ code: "SA", name: "Salerno", zone: "sud" },
|
||||
// Puglia
|
||||
{ code: "BA", name: "Bari", zone: "sud" },
|
||||
{ code: "BT", name: "Barletta-Andria-Trani", zone: "sud" },
|
||||
{ code: "BR", name: "Brindisi", zone: "sud" },
|
||||
{ code: "FG", name: "Foggia", zone: "sud" },
|
||||
{ code: "LE", name: "Lecce", zone: "sud" },
|
||||
{ code: "TA", name: "Taranto", zone: "sud" },
|
||||
// Basilicata
|
||||
{ code: "MT", name: "Matera", zone: "sud" },
|
||||
{ code: "PZ", name: "Potenza", zone: "sud" },
|
||||
// Calabria
|
||||
{ code: "CZ", name: "Catanzaro", zone: "sud" },
|
||||
{ code: "CS", name: "Cosenza", zone: "sud" },
|
||||
{ code: "KR", name: "Crotone", zone: "sud" },
|
||||
{ code: "RC", name: "Reggio Calabria", zone: "sud" },
|
||||
{ code: "VV", name: "Vibo Valentia", zone: "sud" },
|
||||
// Sicilia
|
||||
{ code: "AG", name: "Agrigento", zone: "isole" },
|
||||
{ code: "CL", name: "Caltanissetta", zone: "isole" },
|
||||
{ code: "CT", name: "Catania", zone: "isole" },
|
||||
{ code: "EN", name: "Enna", zone: "isole" },
|
||||
{ code: "ME", name: "Messina", zone: "isole" },
|
||||
{ code: "PA", name: "Palermo", zone: "isole" },
|
||||
{ code: "RG", name: "Ragusa", zone: "isole" },
|
||||
{ code: "SR", name: "Siracusa", zone: "isole" },
|
||||
{ code: "TP", name: "Trapani", zone: "isole" },
|
||||
// Sardegna
|
||||
{ code: "CA", name: "Cagliari", zone: "isole" },
|
||||
{ code: "NU", name: "Nuoro", zone: "isole" },
|
||||
{ code: "OR", name: "Oristano", zone: "isole" },
|
||||
{ code: "SS", name: "Sassari", zone: "isole" },
|
||||
{ code: "SU", name: "Sud Sardegna", zone: "isole" },
|
||||
]
|
||||
|
||||
// --- Fotovoltaico (residenziale) ---
|
||||
|
||||
/** Fattore di produzione per orientamento della falda. */
|
||||
export const PV_ORIENTATION_FACTOR = { sud: 1, est_ovest: 0.85, altro: 0.75, non_so: 0.9 } as const
|
||||
|
||||
/** Taglia residenziale: copertura del fabbisogno, arrotondamento, limiti. */
|
||||
export const PV_SIZING = { coverage: 1, roundToKwp: 0.5, minKwp: 2, maxKwp: 20, roofM2PerKwp: 5.5 } as const
|
||||
|
||||
/** Quota di autoconsumo per uso diurno; con accumulo si aggiunge `batteryBonus` (fino a `max`). */
|
||||
export const PV_SELF_CONSUMPTION = { alto: 0.55, medio: 0.4, basso: 0.3, batteryBonus: 0.3, max: 0.85 } as const
|
||||
|
||||
/** Accumulo: kWh = consumo giornaliero × fattore, arrotondato al taglio disponibile (scaglioni). */
|
||||
export const BATTERY_SIZING = { dailyFactor: 0.5, sizesKwh: [5, 10, 15] } as const
|
||||
|
||||
/** Costo impianto chiavi in mano €/kWp per scaglione di taglia (fino a maxKwp incluso). */
|
||||
export const PV_COST_EUR_PER_KWP: { maxKwp: number; eurPerKwp: number }[] = [
|
||||
{ maxKwp: 3, eurPerKwp: 2000 },
|
||||
{ maxKwp: 6, eurPerKwp: 1700 },
|
||||
{ maxKwp: 10, eurPerKwp: 1500 },
|
||||
{ maxKwp: 20, eurPerKwp: 1350 },
|
||||
{ maxKwp: 100, eurPerKwp: 1150 },
|
||||
{ maxKwp: Number.POSITIVE_INFINITY, eurPerKwp: 1000 },
|
||||
]
|
||||
export const BATTERY_COST_EUR_PER_KWH = 600
|
||||
|
||||
/** Prodotti di riferimento per taglia (handle Medusa). */
|
||||
export const PV_PRODUCTS: { minKwp: number; maxKwp: number; battery: boolean; handle: string; note: string }[] = [
|
||||
{ minKwp: 4.5, maxKwp: 7.5, battery: true, handle: "kit-fotovoltaico-6kw-accumulo", note: "Kit di riferimento 6 kW con accumulo" },
|
||||
]
|
||||
|
||||
// --- Trattamento acqua (addolcitore) ---
|
||||
|
||||
export const WATER = {
|
||||
/** Durezza ipotizzata quando l'utente non la conosce (°f). */
|
||||
defaultHardnessF: 30,
|
||||
/** Durezza residua dopo l'addolcimento (°f): sotto questa soglia l'addolcitore non serve. */
|
||||
targetHardnessF: 15,
|
||||
/** Consumo domestico medio per persona (litri/giorno). */
|
||||
litersPerPersonDay: 200,
|
||||
/** Giorni tra due rigenerazioni. */
|
||||
regenerationDays: 4,
|
||||
/** Capacità di scambio della resina (°f × m³ per litro di resina). */
|
||||
resinCapacityFM3PerLiter: 5,
|
||||
/** Risparmio medio annuo per persona (calcare: energia acqua calda, elettrodomestici, detersivi) a 40 °f. */
|
||||
savingsEurPerPersonAt40F: 45,
|
||||
} as const
|
||||
|
||||
/** Taglie degli addolcitori (litri di resina), costo installato, prodotto di riferimento. */
|
||||
export const SOFTENER_SIZES: { liters: number; costEur: number; handle: string | null }[] = [
|
||||
{ liters: 10, costEur: 1190, handle: "addolcitore-domestico-20l" },
|
||||
{ liters: 20, costEur: 1190, handle: "addolcitore-domestico-20l" },
|
||||
{ liters: 30, costEur: 1590, handle: null },
|
||||
{ liters: 40, costEur: 1890, handle: null },
|
||||
]
|
||||
export const SOFTENER_INSTALLATION_EUR = 300
|
||||
|
||||
// --- Pompa di calore ---
|
||||
|
||||
export type Insulation = "nuovo" | "ristrutturato" | "vecchio"
|
||||
|
||||
/** Potenza specifica di progetto (W/m²) e fabbisogno annuo (kWh termici/m²) per isolamento. */
|
||||
export const HEATING_BY_INSULATION: Record<Insulation, { wattPerM2: number; kwhPerM2Year: number }> = {
|
||||
nuovo: { wattPerM2: 40, kwhPerM2Year: 50 },
|
||||
ristrutturato: { wattPerM2: 60, kwhPerM2Year: 90 },
|
||||
vecchio: { wattPerM2: 90, kwhPerM2Year: 150 },
|
||||
}
|
||||
|
||||
/** Correzione climatica per zona (potenza e fabbisogno). */
|
||||
export const CLIMATE_FACTOR: Record<Zone, number> = { nord: 1.15, centro: 1, sud: 0.85, isole: 0.8 }
|
||||
|
||||
/** Acqua calda sanitaria: potenza aggiuntiva e fabbisogno annuo. */
|
||||
export const DHW = { extraKw: 1.5, kwhPerYear: 2000 } as const
|
||||
|
||||
/** Taglie commerciali (kW), costo installato e prodotto di riferimento. Oltre l'ultima: sopralluogo. */
|
||||
export const HEAT_PUMP_SIZES: { kw: number; costEur: number; handle: string | null }[] = [
|
||||
{ kw: 4, costEur: 6500, handle: null },
|
||||
{ kw: 6, costEur: 7500, handle: null },
|
||||
{ kw: 8, costEur: 8500, handle: "pompa-di-calore-aria-acqua-8kw" },
|
||||
{ kw: 10, costEur: 9500, handle: null },
|
||||
{ kw: 12, costEur: 10500, handle: null },
|
||||
{ kw: 14, costEur: 11500, handle: null },
|
||||
{ kw: 16, costEur: 12500, handle: null },
|
||||
]
|
||||
|
||||
/** SCOP stagionale per terminali di emissione. */
|
||||
export const HEAT_PUMP_SCOP = { pavimento: 4, fancoil: 3.5, radiatori: 3 } as const
|
||||
|
||||
/** Combustibile attuale: prezzo per unità, kWh per unità, rendimento del generatore. */
|
||||
export const FUELS = {
|
||||
metano: { label: "Metano", eurPerUnit: 1.1, kwhPerUnit: 9.6, efficiency: 0.9 },
|
||||
gpl: { label: "GPL", eurPerUnit: 0.85, kwhPerUnit: 6.8, efficiency: 0.9 },
|
||||
gasolio: { label: "Gasolio", eurPerUnit: 1.65, kwhPerUnit: 10, efficiency: 0.85 },
|
||||
elettrico: { label: "Elettrico", eurPerUnit: ELECTRICITY_PRICE_EUR_KWH, kwhPerUnit: 1, efficiency: 1 },
|
||||
} as const
|
||||
|
||||
// --- Aziende ---
|
||||
|
||||
export const BUSINESS_PV = { coverage: 0.7, selfConsumption: 0.7, minKwp: 6, maxKwp: 500 } as const
|
||||
|
||||
// --- Testi statici ---
|
||||
|
||||
export const INCENTIVES_NOTE: Record<"acqua" | "fotovoltaico" | "pompa_calore" | "azienda", string> = {
|
||||
acqua:
|
||||
"L'addolcitore può rientrare nelle detrazioni fiscali per interventi edilizi se installato nell'ambito di una ristrutturazione. Aliquote e requisiti cambiano nel tempo: li verifichiamo con te in fase di preventivo.",
|
||||
fotovoltaico:
|
||||
"Per i privati l'impianto fotovoltaico con eventuale accumulo può accedere alle detrazioni fiscali per interventi edilizi; l'energia immessa in rete può essere valorizzata con il ritiro dedicato. Aliquote e requisiti cambiano nel tempo: li verifichiamo con te in fase di preventivo.",
|
||||
pompa_calore:
|
||||
"La sostituzione del generatore con una pompa di calore può accedere a detrazioni fiscali o al Conto Termico, a seconda dei requisiti dell'edificio e dell'intervento. Li verifichiamo con te in fase di preventivo.",
|
||||
azienda:
|
||||
"Per le imprese sono disponibili strumenti di incentivazione per efficienza energetica e rinnovabili (bandi nazionali e regionali, crediti d'imposta). Li valutiamo insieme in fase di consulenza.",
|
||||
}
|
||||
|
||||
export const DISCLAIMER =
|
||||
"Stima indicativa basata su valori medi: non costituisce un'offerta. Il preventivo definitivo richiede la verifica di un nostro tecnico, di norma con sopralluogo."
|
||||
@@ -0,0 +1,20 @@
|
||||
import { drizzle } from "drizzle-orm/postgres-js"
|
||||
import postgres from "postgres"
|
||||
import * as schema from "./schema/index.js"
|
||||
|
||||
export type Database = ReturnType<typeof createDb>["db"]
|
||||
/** Transazione Drizzle (argomento della callback di db.transaction). */
|
||||
export type Transaction = Parameters<Parameters<Database["transaction"]>[0]>[0]
|
||||
/** Istanza o transazione: per funzioni di servizio usabili in entrambi i contesti. */
|
||||
export type DbOrTx = Database | Transaction
|
||||
|
||||
/** Crea client postgres.js + istanza Drizzle. Chiudere con `close()` (es. onClose di Fastify). */
|
||||
export function createDb(databaseUrl: string, options: { max?: number } = {}) {
|
||||
const client = postgres(databaseUrl, {
|
||||
max: options.max ?? 10,
|
||||
// Notice di Postgres (es. "relation already exists, skipping") non servono nei log
|
||||
onnotice: () => {},
|
||||
})
|
||||
const db = drizzle(client, { schema })
|
||||
return { db, close: () => client.end({ timeout: 5 }) }
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { fileURLToPath } from "node:url"
|
||||
import { migrate } from "drizzle-orm/postgres-js/migrator"
|
||||
import { ConfigError, dbEnvSchema, parseEnv } from "../config/env.js"
|
||||
import { createDb } from "./client.js"
|
||||
|
||||
/**
|
||||
* Applica le migrazioni versionate (src/db/migrations, generate con `npm run db:generate`).
|
||||
* Uso: `npm run db:migrate -w apps/api`. Il percorso è risolto dalla root del package,
|
||||
* quindi funziona sia da src/ (tsx) sia da dist/ (node), purché src/db/migrations sia presente.
|
||||
*/
|
||||
const MIGRATIONS_FOLDER = fileURLToPath(new URL("../../src/db/migrations", import.meta.url))
|
||||
|
||||
try {
|
||||
const { DATABASE_URL } = parseEnv(dbEnvSchema)
|
||||
const { db, close } = createDb(DATABASE_URL, { max: 1 })
|
||||
try {
|
||||
await migrate(db, { migrationsFolder: MIGRATIONS_FOLDER })
|
||||
console.log(`[db:migrate] migrazioni applicate (${MIGRATIONS_FOLDER})`)
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof ConfigError ? `[db:migrate] ${err.message}` : err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
CREATE TYPE "public"."user_role" AS ENUM('customer', 'company', 'installer', 'admin');--> statement-breakpoint
|
||||
CREATE TABLE "email_verification_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "email_verification_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "password_reset_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"used_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "password_reset_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "refresh_tokens" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"token_hash" text NOT NULL,
|
||||
"family_id" uuid NOT NULL,
|
||||
"expires_at" timestamp with time zone NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"user_agent" text,
|
||||
"ip" "inet",
|
||||
CONSTRAINT "refresh_tokens_token_hash_unique" UNIQUE("token_hash")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "users" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"password_hash" text NOT NULL,
|
||||
"role" "user_role" DEFAULT 'customer' NOT NULL,
|
||||
"first_name" text NOT NULL,
|
||||
"last_name" text NOT NULL,
|
||||
"company_name" text,
|
||||
"vat_number" text,
|
||||
"medusa_customer_id" text,
|
||||
"email_verified_at" timestamp with time zone,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"disabled_at" timestamp with time zone,
|
||||
CONSTRAINT "users_medusa_customer_id_unique" UNIQUE("medusa_customer_id"),
|
||||
CONSTRAINT "users_email_lowercase" CHECK ("users"."email" = lower("users"."email"))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "email_verification_tokens" ADD CONSTRAINT "email_verification_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "password_reset_tokens" ADD CONSTRAINT "password_reset_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "refresh_tokens" ADD CONSTRAINT "refresh_tokens_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "email_verification_tokens_user_id_idx" ON "email_verification_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "password_reset_tokens_user_id_idx" ON "password_reset_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "refresh_tokens_user_id_idx" ON "refresh_tokens" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "refresh_tokens_family_id_idx" ON "refresh_tokens" USING btree ("family_id");--> statement-breakpoint
|
||||
CREATE UNIQUE INDEX "users_email_lower_key" ON "users" USING btree (lower("email"));
|
||||
@@ -0,0 +1,11 @@
|
||||
-- Wrapper IMMUTABLE di unaccent(), necessario per usarlo nella colonna generata
|
||||
-- blog_posts.search_vector (le colonne generate ammettono solo funzioni IMMUTABLE;
|
||||
-- unaccent(text) è STABLE perché dipende dal search_path).
|
||||
-- Il dizionario è indicato esplicitamente e qualificato con lo schema, quindi il risultato
|
||||
-- non dipende dal search_path: dichiararlo IMMUTABLE è sicuro.
|
||||
-- L'estensione unaccent è creata dall'init del container (infra/postgres/init/01-init.sql).
|
||||
CREATE OR REPLACE FUNCTION public.immutable_unaccent(text)
|
||||
RETURNS text
|
||||
LANGUAGE sql
|
||||
IMMUTABLE PARALLEL SAFE STRICT
|
||||
AS $$ SELECT public.unaccent('public.unaccent'::regdictionary, $1) $$;
|
||||
@@ -0,0 +1,205 @@
|
||||
CREATE TYPE "public"."blog_post_status" AS ENUM('draft', 'published');--> statement-breakpoint
|
||||
CREATE TYPE "public"."consent_type" AS ENUM('privacy', 'marketing');--> statement-breakpoint
|
||||
CREATE TYPE "public"."contact_request_status" AS ENUM('new', 'in_progress', 'closed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."document_visibility" AS ENUM('public', 'customer', 'installer', 'admin');--> statement-breakpoint
|
||||
CREATE TYPE "public"."installer_status" AS ENUM('pending', 'approved', 'rejected');--> statement-breakpoint
|
||||
CREATE TYPE "public"."quote_request_status" AS ENUM('new', 'contacted', 'converted', 'closed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."quote_status" AS ENUM('draft', 'sent', 'accepted', 'rejected', 'expired');--> statement-breakpoint
|
||||
CREATE TYPE "public"."showroom_booking_status" AS ENUM('requested', 'confirmed', 'cancelled', 'completed');--> statement-breakpoint
|
||||
CREATE TYPE "public"."ticket_priority" AS ENUM('low', 'normal', 'high', 'urgent');--> statement-breakpoint
|
||||
CREATE TYPE "public"."ticket_status" AS ENUM('open', 'in_progress', 'waiting_customer', 'closed');--> statement-breakpoint
|
||||
CREATE TABLE "blog_categories" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"description" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "blog_categories_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "blog_posts" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"slug" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"excerpt" text,
|
||||
"content_md" text DEFAULT '' NOT NULL,
|
||||
"cover_path" text,
|
||||
"status" "blog_post_status" DEFAULT 'draft' NOT NULL,
|
||||
"published_at" timestamp with time zone,
|
||||
"seo_title" text,
|
||||
"seo_description" text,
|
||||
"category_id" uuid,
|
||||
"author_id" uuid,
|
||||
"search_vector" "tsvector" GENERATED ALWAYS AS (setweight(to_tsvector('italian', immutable_unaccent(coalesce(title, ''))), 'A') || setweight(to_tsvector('italian', immutable_unaccent(coalesce(excerpt, ''))), 'B') || setweight(to_tsvector('italian', immutable_unaccent(coalesce(content_md, ''))), 'C')) STORED,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "blog_posts_slug_unique" UNIQUE("slug")
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "consents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"email" text,
|
||||
"type" "consent_type" NOT NULL,
|
||||
"version" text NOT NULL,
|
||||
"source" text,
|
||||
"accepted_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"revoked_at" timestamp with time zone,
|
||||
"ip" "inet",
|
||||
CONSTRAINT "consents_subject_present" CHECK (num_nonnulls("consents"."user_id", "consents"."email") >= 1),
|
||||
CONSTRAINT "consents_email_lowercase" CHECK ("consents"."email" = lower("consents"."email"))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "contact_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"phone" text,
|
||||
"subject" text,
|
||||
"message" text NOT NULL,
|
||||
"status" "contact_request_status" DEFAULT 'new' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "contact_requests_email_lowercase" CHECK ("contact_requests"."email" = lower("contact_requests"."email"))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "documents" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"category" text,
|
||||
"file_path" text NOT NULL,
|
||||
"mime_type" text NOT NULL,
|
||||
"size_bytes" bigint NOT NULL,
|
||||
"visibility" "document_visibility" DEFAULT 'admin' NOT NULL,
|
||||
"product_ref" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "installer_profiles" (
|
||||
"user_id" uuid PRIMARY KEY NOT NULL,
|
||||
"business_name" text NOT NULL,
|
||||
"vat_number" text,
|
||||
"phone" text,
|
||||
"address" text,
|
||||
"location" geography(Point,4326), -- tipo non quotato: drizzle-kit lo quota come identificatore (corretto a mano)
|
||||
"service_radius_km" integer DEFAULT 50 NOT NULL,
|
||||
"certifications" text[] DEFAULT '{}'::text[] NOT NULL,
|
||||
"status" "installer_status" DEFAULT 'pending' NOT NULL,
|
||||
"approved_at" timestamp with time zone,
|
||||
"notes" text,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "installer_profiles_radius_positive" CHECK ("installer_profiles"."service_radius_km" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "quote_requests" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"email" text NOT NULL,
|
||||
"name" text NOT NULL,
|
||||
"phone" text,
|
||||
"province" text,
|
||||
"configurator_input" jsonb NOT NULL,
|
||||
"configurator_output" jsonb,
|
||||
"status" "quote_request_status" DEFAULT 'new' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "quote_requests_email_lowercase" CHECK ("quote_requests"."email" = lower("quote_requests"."email"))
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "quotes" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"quote_request_id" uuid,
|
||||
"user_id" uuid NOT NULL,
|
||||
"year" smallint NOT NULL,
|
||||
"seq" integer NOT NULL,
|
||||
"number" text NOT NULL,
|
||||
"title" text NOT NULL,
|
||||
"status" "quote_status" DEFAULT 'draft' NOT NULL,
|
||||
"amount_cents" bigint NOT NULL,
|
||||
"pdf_document_id" uuid,
|
||||
"valid_until" date,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "quotes_number_unique" UNIQUE("number"),
|
||||
CONSTRAINT "quotes_year_seq_key" UNIQUE("year","seq"),
|
||||
CONSTRAINT "quotes_amount_non_negative" CHECK ("quotes"."amount_cents" >= 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "showroom_bookings" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid,
|
||||
"name" text NOT NULL,
|
||||
"email" text NOT NULL,
|
||||
"phone" text,
|
||||
"requested_at" timestamp with time zone NOT NULL,
|
||||
"people" smallint DEFAULT 1 NOT NULL,
|
||||
"notes" text,
|
||||
"status" "showroom_booking_status" DEFAULT 'requested' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
CONSTRAINT "showroom_bookings_email_lowercase" CHECK ("showroom_bookings"."email" = lower("showroom_bookings"."email")),
|
||||
CONSTRAINT "showroom_bookings_people_positive" CHECK ("showroom_bookings"."people" > 0)
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "ticket_messages" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"ticket_id" uuid NOT NULL,
|
||||
"author_id" uuid NOT NULL,
|
||||
"body" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "tickets" (
|
||||
"id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL,
|
||||
"user_id" uuid NOT NULL,
|
||||
"subject" text NOT NULL,
|
||||
"status" "ticket_status" DEFAULT 'open' NOT NULL,
|
||||
"priority" "ticket_priority" DEFAULT 'normal' NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"updated_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
CREATE TABLE "webhook_events" (
|
||||
"id" uuid PRIMARY KEY NOT NULL,
|
||||
"event" text NOT NULL,
|
||||
"payload" jsonb NOT NULL,
|
||||
"received_at" timestamp with time zone DEFAULT now() NOT NULL,
|
||||
"processed_at" timestamp with time zone,
|
||||
"error" text
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_category_id_blog_categories_id_fk" FOREIGN KEY ("category_id") REFERENCES "public"."blog_categories"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "blog_posts" ADD CONSTRAINT "blog_posts_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "consents" ADD CONSTRAINT "consents_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "contact_requests" ADD CONSTRAINT "contact_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "installer_profiles" ADD CONSTRAINT "installer_profiles_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "quote_requests" ADD CONSTRAINT "quote_requests_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "quotes" ADD CONSTRAINT "quotes_quote_request_id_quote_requests_id_fk" FOREIGN KEY ("quote_request_id") REFERENCES "public"."quote_requests"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "quotes" ADD CONSTRAINT "quotes_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "quotes" ADD CONSTRAINT "quotes_pdf_document_id_documents_id_fk" FOREIGN KEY ("pdf_document_id") REFERENCES "public"."documents"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "showroom_bookings" ADD CONSTRAINT "showroom_bookings_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ticket_messages" ADD CONSTRAINT "ticket_messages_ticket_id_tickets_id_fk" FOREIGN KEY ("ticket_id") REFERENCES "public"."tickets"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "ticket_messages" ADD CONSTRAINT "ticket_messages_author_id_users_id_fk" FOREIGN KEY ("author_id") REFERENCES "public"."users"("id") ON DELETE restrict ON UPDATE no action;--> statement-breakpoint
|
||||
ALTER TABLE "tickets" ADD CONSTRAINT "tickets_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "blog_posts_search_vector_gin" ON "blog_posts" USING gin ("search_vector");--> statement-breakpoint
|
||||
CREATE INDEX "blog_posts_published_idx" ON "blog_posts" USING btree ("status","published_at");--> statement-breakpoint
|
||||
CREATE INDEX "blog_posts_category_id_idx" ON "blog_posts" USING btree ("category_id");--> statement-breakpoint
|
||||
CREATE INDEX "consents_user_id_idx" ON "consents" USING btree ("user_id","type");--> statement-breakpoint
|
||||
CREATE INDEX "consents_email_idx" ON "consents" USING btree ("email","type");--> statement-breakpoint
|
||||
CREATE INDEX "contact_requests_status_idx" ON "contact_requests" USING btree ("status","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "documents_visibility_idx" ON "documents" USING btree ("visibility");--> statement-breakpoint
|
||||
CREATE INDEX "documents_product_ref_idx" ON "documents" USING btree ("product_ref");--> statement-breakpoint
|
||||
CREATE INDEX "installer_profiles_location_gist" ON "installer_profiles" USING gist ("location");--> statement-breakpoint
|
||||
CREATE INDEX "installer_profiles_status_idx" ON "installer_profiles" USING btree ("status");--> statement-breakpoint
|
||||
CREATE INDEX "quote_requests_email_idx" ON "quote_requests" USING btree ("email");--> statement-breakpoint
|
||||
CREATE INDEX "quote_requests_user_id_idx" ON "quote_requests" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "quote_requests_status_idx" ON "quote_requests" USING btree ("status","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "quotes_user_id_idx" ON "quotes" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "showroom_bookings_requested_at_idx" ON "showroom_bookings" USING btree ("requested_at");--> statement-breakpoint
|
||||
CREATE INDEX "ticket_messages_ticket_id_idx" ON "ticket_messages" USING btree ("ticket_id","created_at");--> statement-breakpoint
|
||||
CREATE INDEX "tickets_user_id_idx" ON "tickets" USING btree ("user_id");--> statement-breakpoint
|
||||
CREATE INDEX "tickets_status_idx" ON "tickets" USING btree ("status","priority");
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "quotes" ADD COLUMN "sent_at" timestamp with time zone;
|
||||
@@ -0,0 +1,455 @@
|
||||
{
|
||||
"id": "acf95356-392a-4d4d-8264-2eea9a22f01e",
|
||||
"prevId": "00000000-0000-0000-0000-000000000000",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.email_verification_tokens": {
|
||||
"name": "email_verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"email_verification_tokens_user_id_idx": {
|
||||
"name": "email_verification_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"email_verification_tokens_user_id_users_id_fk": {
|
||||
"name": "email_verification_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "email_verification_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"email_verification_tokens_token_hash_unique": {
|
||||
"name": "email_verification_tokens_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.password_reset_tokens": {
|
||||
"name": "password_reset_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"password_reset_tokens_user_id_idx": {
|
||||
"name": "password_reset_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"password_reset_tokens_user_id_users_id_fk": {
|
||||
"name": "password_reset_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "password_reset_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"password_reset_tokens_token_hash_unique": {
|
||||
"name": "password_reset_tokens_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.refresh_tokens": {
|
||||
"name": "refresh_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"family_id": {
|
||||
"name": "family_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "inet",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"refresh_tokens_user_id_idx": {
|
||||
"name": "refresh_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"refresh_tokens_family_id_idx": {
|
||||
"name": "refresh_tokens_family_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "family_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"refresh_tokens_user_id_users_id_fk": {
|
||||
"name": "refresh_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "refresh_tokens",
|
||||
"tableTo": "users",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onDelete": "cascade",
|
||||
"onUpdate": "no action"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"refresh_tokens_token_hash_unique": {
|
||||
"name": "refresh_tokens_token_hash_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"token_hash"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "user_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'customer'"
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"company_name": {
|
||||
"name": "company_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"vat_number": {
|
||||
"name": "vat_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"medusa_customer_id": {
|
||||
"name": "medusa_customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email_verified_at": {
|
||||
"name": "email_verified_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"disabled_at": {
|
||||
"name": "disabled_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_lower_key": {
|
||||
"name": "users_email_lower_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "lower(\"email\")",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_medusa_customer_id_unique": {
|
||||
"name": "users_medusa_customer_id_unique",
|
||||
"nullsNotDistinct": false,
|
||||
"columns": [
|
||||
"medusa_customer_id"
|
||||
]
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"users_email_lowercase": {
|
||||
"name": "users_email_lowercase",
|
||||
"value": "\"users\".\"email\" = lower(\"users\".\"email\")"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.user_role": {
|
||||
"name": "user_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"customer",
|
||||
"company",
|
||||
"installer",
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"views": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,455 @@
|
||||
{
|
||||
"id": "af5484ea-7caf-4bdf-9aec-5d6ff568b1ed",
|
||||
"prevId": "acf95356-392a-4d4d-8264-2eea9a22f01e",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
"public.email_verification_tokens": {
|
||||
"name": "email_verification_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"email_verification_tokens_user_id_idx": {
|
||||
"name": "email_verification_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"with": {},
|
||||
"method": "btree",
|
||||
"concurrently": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"email_verification_tokens_user_id_users_id_fk": {
|
||||
"name": "email_verification_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "email_verification_tokens",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"email_verification_tokens_token_hash_unique": {
|
||||
"name": "email_verification_tokens_token_hash_unique",
|
||||
"columns": [
|
||||
"token_hash"
|
||||
],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.password_reset_tokens": {
|
||||
"name": "password_reset_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"used_at": {
|
||||
"name": "used_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"password_reset_tokens_user_id_idx": {
|
||||
"name": "password_reset_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"with": {},
|
||||
"method": "btree",
|
||||
"concurrently": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"password_reset_tokens_user_id_users_id_fk": {
|
||||
"name": "password_reset_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "password_reset_tokens",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"password_reset_tokens_token_hash_unique": {
|
||||
"name": "password_reset_tokens_token_hash_unique",
|
||||
"columns": [
|
||||
"token_hash"
|
||||
],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.refresh_tokens": {
|
||||
"name": "refresh_tokens",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"user_id": {
|
||||
"name": "user_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"token_hash": {
|
||||
"name": "token_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"family_id": {
|
||||
"name": "family_id",
|
||||
"type": "uuid",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"expires_at": {
|
||||
"name": "expires_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"revoked_at": {
|
||||
"name": "revoked_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"user_agent": {
|
||||
"name": "user_agent",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"ip": {
|
||||
"name": "ip",
|
||||
"type": "inet",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"refresh_tokens_user_id_idx": {
|
||||
"name": "refresh_tokens_user_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "user_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"with": {},
|
||||
"method": "btree",
|
||||
"concurrently": false
|
||||
},
|
||||
"refresh_tokens_family_id_idx": {
|
||||
"name": "refresh_tokens_family_id_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "family_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"with": {},
|
||||
"method": "btree",
|
||||
"concurrently": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
"refresh_tokens_user_id_users_id_fk": {
|
||||
"name": "refresh_tokens_user_id_users_id_fk",
|
||||
"tableFrom": "refresh_tokens",
|
||||
"columnsFrom": [
|
||||
"user_id"
|
||||
],
|
||||
"tableTo": "users",
|
||||
"columnsTo": [
|
||||
"id"
|
||||
],
|
||||
"onUpdate": "no action",
|
||||
"onDelete": "cascade"
|
||||
}
|
||||
},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"refresh_tokens_token_hash_unique": {
|
||||
"name": "refresh_tokens_token_hash_unique",
|
||||
"columns": [
|
||||
"token_hash"
|
||||
],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {},
|
||||
"isRLSEnabled": false
|
||||
},
|
||||
"public.users": {
|
||||
"name": "users",
|
||||
"schema": "",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "uuid",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"default": "gen_random_uuid()"
|
||||
},
|
||||
"email": {
|
||||
"name": "email",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"password_hash": {
|
||||
"name": "password_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "user_role",
|
||||
"typeSchema": "public",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "'customer'"
|
||||
},
|
||||
"first_name": {
|
||||
"name": "first_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"last_name": {
|
||||
"name": "last_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true
|
||||
},
|
||||
"company_name": {
|
||||
"name": "company_name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"vat_number": {
|
||||
"name": "vat_number",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"medusa_customer_id": {
|
||||
"name": "medusa_customer_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"email_verified_at": {
|
||||
"name": "email_verified_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": "now()"
|
||||
},
|
||||
"disabled_at": {
|
||||
"name": "disabled_at",
|
||||
"type": "timestamp with time zone",
|
||||
"primaryKey": false,
|
||||
"notNull": false
|
||||
}
|
||||
},
|
||||
"indexes": {
|
||||
"users_email_lower_key": {
|
||||
"name": "users_email_lower_key",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "lower(\"email\")",
|
||||
"isExpression": true,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": true,
|
||||
"with": {},
|
||||
"method": "btree",
|
||||
"concurrently": false
|
||||
}
|
||||
},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {
|
||||
"users_medusa_customer_id_unique": {
|
||||
"name": "users_medusa_customer_id_unique",
|
||||
"columns": [
|
||||
"medusa_customer_id"
|
||||
],
|
||||
"nullsNotDistinct": false
|
||||
}
|
||||
},
|
||||
"policies": {},
|
||||
"checkConstraints": {
|
||||
"users_email_lowercase": {
|
||||
"name": "users_email_lowercase",
|
||||
"value": "\"users\".\"email\" = lower(\"users\".\"email\")"
|
||||
}
|
||||
},
|
||||
"isRLSEnabled": false
|
||||
}
|
||||
},
|
||||
"enums": {
|
||||
"public.user_role": {
|
||||
"name": "user_role",
|
||||
"schema": "public",
|
||||
"values": [
|
||||
"customer",
|
||||
"company",
|
||||
"installer",
|
||||
"admin"
|
||||
]
|
||||
}
|
||||
},
|
||||
"schemas": {},
|
||||
"views": {},
|
||||
"sequences": {},
|
||||
"roles": {},
|
||||
"policies": {},
|
||||
"_meta": {
|
||||
"columns": {},
|
||||
"schemas": {},
|
||||
"tables": {}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"entries": [
|
||||
{
|
||||
"idx": 0,
|
||||
"version": "7",
|
||||
"when": 1790968058035,
|
||||
"tag": "0000_init_users",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 1,
|
||||
"version": "7",
|
||||
"when": 1790968602533,
|
||||
"tag": "0001_immutable_unaccent",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 2,
|
||||
"version": "7",
|
||||
"when": 1790968661424,
|
||||
"tag": "0002_domain",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 3,
|
||||
"version": "7",
|
||||
"when": 1790974460847,
|
||||
"tag": "0003_quotes_sent_at",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
import type { GeoPoint } from "@iti/shared"
|
||||
import { sql } from "drizzle-orm"
|
||||
import { customType, timestamp } from "drizzle-orm/pg-core"
|
||||
|
||||
/** created_at timestamptz not null default now() */
|
||||
export const createdAt = () => timestamp("created_at", { withTimezone: true }).notNull().defaultNow()
|
||||
|
||||
/** updated_at timestamptz not null default now(), aggiornato da Drizzle a ogni update */
|
||||
export const updatedAt = () =>
|
||||
timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
.$onUpdate(() => new Date())
|
||||
|
||||
/**
|
||||
* Legge un punto da EWKB esadecimale (formato in cui Postgres restituisce geography/geometry).
|
||||
* Gestisce endianness e flag SRID; per altri tipi geometrici lancia errore.
|
||||
*/
|
||||
export function parseEwkbPoint(hex: string): GeoPoint {
|
||||
const buf = Buffer.from(hex, "hex")
|
||||
const le = buf.readUInt8(0) === 1
|
||||
const type = le ? buf.readUInt32LE(1) : buf.readUInt32BE(1)
|
||||
const hasSrid = (type & 0x20000000) !== 0
|
||||
if ((type & 0xff) !== 1) throw new Error(`EWKB: atteso Point, trovato tipo ${type & 0xff}`)
|
||||
const offset = 5 + (hasSrid ? 4 : 0)
|
||||
const x = le ? buf.readDoubleLE(offset) : buf.readDoubleBE(offset)
|
||||
const y = le ? buf.readDoubleLE(offset + 8) : buf.readDoubleBE(offset + 8)
|
||||
return { lng: x, lat: y }
|
||||
}
|
||||
|
||||
/**
|
||||
* geography(Point,4326) di PostGIS mappata su { lat, lng }.
|
||||
* Scrittura: ST_SetSRID(ST_MakePoint(lng, lat), 4326)::geography. Lettura: EWKB → { lat, lng }.
|
||||
* Per calcoli (ST_DWithin, ST_Distance) usare la colonna direttamente in sql`...` (distanze in metri).
|
||||
*/
|
||||
export const geographyPoint = customType<{ data: GeoPoint; driverData: string }>({
|
||||
dataType() {
|
||||
return "geography(Point,4326)"
|
||||
},
|
||||
toDriver(value) {
|
||||
return sql`ST_SetSRID(ST_MakePoint(${value.lng}, ${value.lat}), 4326)::geography` as unknown as string
|
||||
},
|
||||
fromDriver(value) {
|
||||
return parseEwkbPoint(value)
|
||||
},
|
||||
})
|
||||
|
||||
/** tsvector (sola lettura: usato per colonne generate). */
|
||||
export const tsvector = customType<{ data: string }>({
|
||||
dataType() {
|
||||
return "tsvector"
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,331 @@
|
||||
import {
|
||||
BLOG_POST_STATUSES,
|
||||
CONSENT_TYPES,
|
||||
CONTACT_REQUEST_STATUSES,
|
||||
DOCUMENT_VISIBILITIES,
|
||||
INSTALLER_STATUSES,
|
||||
QUOTE_REQUEST_STATUSES,
|
||||
QUOTE_STATUSES,
|
||||
SHOWROOM_BOOKING_STATUSES,
|
||||
TICKET_PRIORITIES,
|
||||
TICKET_STATUSES,
|
||||
} from "@iti/shared"
|
||||
import { sql } from "drizzle-orm"
|
||||
import {
|
||||
bigint,
|
||||
check,
|
||||
date,
|
||||
index,
|
||||
inet,
|
||||
integer,
|
||||
jsonb,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
smallint,
|
||||
text,
|
||||
timestamp,
|
||||
unique,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { createdAt, geographyPoint, tsvector, updatedAt } from "./columns.js"
|
||||
import { users } from "./users.js"
|
||||
|
||||
// --- Enum ---
|
||||
export const installerStatus = pgEnum("installer_status", INSTALLER_STATUSES)
|
||||
export const quoteRequestStatus = pgEnum("quote_request_status", QUOTE_REQUEST_STATUSES)
|
||||
export const quoteStatus = pgEnum("quote_status", QUOTE_STATUSES)
|
||||
export const ticketStatus = pgEnum("ticket_status", TICKET_STATUSES)
|
||||
export const ticketPriority = pgEnum("ticket_priority", TICKET_PRIORITIES)
|
||||
export const blogPostStatus = pgEnum("blog_post_status", BLOG_POST_STATUSES)
|
||||
export const documentVisibility = pgEnum("document_visibility", DOCUMENT_VISIBILITIES)
|
||||
export const contactRequestStatus = pgEnum("contact_request_status", CONTACT_REQUEST_STATUSES)
|
||||
export const showroomBookingStatus = pgEnum("showroom_booking_status", SHOWROOM_BOOKING_STATUSES)
|
||||
export const consentType = pgEnum("consent_type", CONSENT_TYPES)
|
||||
|
||||
/** Email salvata minuscola: stesso vincolo di users.email. */
|
||||
const lowercaseEmailCheck = (name: string, column: unknown) => check(name, sql`${column} = lower(${column})`)
|
||||
|
||||
// --- Installatori ---
|
||||
export const installerProfiles = pgTable(
|
||||
"installer_profiles",
|
||||
{
|
||||
userId: uuid("user_id")
|
||||
.primaryKey()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
businessName: text("business_name").notNull(),
|
||||
vatNumber: text("vat_number"),
|
||||
phone: text("phone"),
|
||||
address: text("address"),
|
||||
/** Sede/punto di partenza; con service_radius_km definisce l'area servita. */
|
||||
location: geographyPoint("location"),
|
||||
serviceRadiusKm: integer("service_radius_km").notNull().default(50),
|
||||
certifications: text("certifications").array().notNull().default(sql`'{}'::text[]`),
|
||||
status: installerStatus("status").notNull().default("pending"),
|
||||
approvedAt: timestamp("approved_at", { withTimezone: true }),
|
||||
/** Note interne dell'admin, mai esposte all'installatore. */
|
||||
notes: text("notes"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("installer_profiles_location_gist").using("gist", t.location),
|
||||
index("installer_profiles_status_idx").on(t.status),
|
||||
check("installer_profiles_radius_positive", sql`${t.serviceRadiusKm} > 0`),
|
||||
],
|
||||
)
|
||||
|
||||
// --- Documenti (schede tecniche, CAD, PDF preventivi) ---
|
||||
export const documents = pgTable(
|
||||
"documents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
title: text("title").notNull(),
|
||||
category: text("category"),
|
||||
/** Percorso relativo all'area private/ dello storage (mai esposto ai client). */
|
||||
filePath: text("file_path").notNull(),
|
||||
mimeType: text("mime_type").notNull(),
|
||||
sizeBytes: bigint("size_bytes", { mode: "number" }).notNull(),
|
||||
visibility: documentVisibility("visibility").notNull().default("admin"),
|
||||
/** Riferimento opzionale a un prodotto Medusa (handle). */
|
||||
productRef: text("product_ref"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("documents_visibility_idx").on(t.visibility), index("documents_product_ref_idx").on(t.productRef)],
|
||||
)
|
||||
|
||||
// --- Richieste preventivo (output del configuratore) e preventivi ---
|
||||
export const quoteRequests = pgTable(
|
||||
"quote_requests",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** Null per richieste anonime; collegate all'account alla registrazione (stessa email). */
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
email: text("email").notNull(),
|
||||
name: text("name").notNull(),
|
||||
phone: text("phone"),
|
||||
/** Sigla provincia (es. "MI"), usata per produttività FV e installatori vicini. */
|
||||
province: text("province"),
|
||||
configuratorInput: jsonb("configurator_input").notNull(),
|
||||
configuratorOutput: jsonb("configurator_output"),
|
||||
status: quoteRequestStatus("status").notNull().default("new"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
lowercaseEmailCheck("quote_requests_email_lowercase", t.email),
|
||||
index("quote_requests_email_idx").on(t.email),
|
||||
index("quote_requests_user_id_idx").on(t.userId),
|
||||
index("quote_requests_status_idx").on(t.status, t.createdAt),
|
||||
],
|
||||
)
|
||||
|
||||
export const quotes = pgTable(
|
||||
"quotes",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
quoteRequestId: uuid("quote_request_id").references(() => quoteRequests.id, { onDelete: "set null" }),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "restrict" }),
|
||||
/** Progressivo per anno: number = `${year}-${seq a 4 cifre}` (es. 2026-0001), unico. */
|
||||
year: smallint("year").notNull(),
|
||||
seq: integer("seq").notNull(),
|
||||
number: text("number").notNull().unique(),
|
||||
title: text("title").notNull(),
|
||||
status: quoteStatus("status").notNull().default("draft"),
|
||||
amountCents: bigint("amount_cents", { mode: "number" }).notNull(),
|
||||
pdfDocumentId: uuid("pdf_document_id").references(() => documents.id, { onDelete: "set null" }),
|
||||
validUntil: date("valid_until"),
|
||||
/** Primo invio al cliente (stato sent). */
|
||||
sentAt: timestamp("sent_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
unique("quotes_year_seq_key").on(t.year, t.seq),
|
||||
index("quotes_user_id_idx").on(t.userId),
|
||||
check("quotes_amount_non_negative", sql`${t.amountCents} >= 0`),
|
||||
],
|
||||
)
|
||||
|
||||
// --- Assistenza ---
|
||||
export const tickets = pgTable(
|
||||
"tickets",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
subject: text("subject").notNull(),
|
||||
status: ticketStatus("status").notNull().default("open"),
|
||||
priority: ticketPriority("priority").notNull().default("normal"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [index("tickets_user_id_idx").on(t.userId), index("tickets_status_idx").on(t.status, t.priority)],
|
||||
)
|
||||
|
||||
export const ticketMessages = pgTable(
|
||||
"ticket_messages",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
ticketId: uuid("ticket_id")
|
||||
.notNull()
|
||||
.references(() => tickets.id, { onDelete: "cascade" }),
|
||||
authorId: uuid("author_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "restrict" }),
|
||||
body: text("body").notNull(),
|
||||
createdAt: createdAt(),
|
||||
},
|
||||
(t) => [index("ticket_messages_ticket_id_idx").on(t.ticketId, t.createdAt)],
|
||||
)
|
||||
|
||||
// --- Blog ---
|
||||
export const blogCategories = pgTable("blog_categories", {
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
name: text("name").notNull(),
|
||||
description: text("description"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
})
|
||||
|
||||
export const blogPosts = pgTable(
|
||||
"blog_posts",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
slug: text("slug").notNull().unique(),
|
||||
title: text("title").notNull(),
|
||||
excerpt: text("excerpt"),
|
||||
contentMd: text("content_md").notNull().default(""),
|
||||
/** Percorso relativo nell'area public/ dello storage. */
|
||||
coverPath: text("cover_path"),
|
||||
status: blogPostStatus("status").notNull().default("draft"),
|
||||
publishedAt: timestamp("published_at", { withTimezone: true }),
|
||||
seoTitle: text("seo_title"),
|
||||
seoDescription: text("seo_description"),
|
||||
categoryId: uuid("category_id").references(() => blogCategories.id, { onDelete: "set null" }),
|
||||
authorId: uuid("author_id").references(() => users.id, { onDelete: "set null" }),
|
||||
/**
|
||||
* Full-text italiano senza accenti, pesato: titolo (A), estratto (B), contenuto (C).
|
||||
* Query: search_vector @@ websearch_to_tsquery('italian', unaccent(q)), rank con ts_rank.
|
||||
* immutable_unaccent: vedi migrazione 0001.
|
||||
*/
|
||||
searchVector: tsvector("search_vector").generatedAlwaysAs(
|
||||
sql`setweight(to_tsvector('italian', immutable_unaccent(coalesce(title, ''))), 'A') || setweight(to_tsvector('italian', immutable_unaccent(coalesce(excerpt, ''))), 'B') || setweight(to_tsvector('italian', immutable_unaccent(coalesce(content_md, ''))), 'C')`,
|
||||
),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
index("blog_posts_search_vector_gin").using("gin", t.searchVector),
|
||||
index("blog_posts_published_idx").on(t.status, t.publishedAt),
|
||||
index("blog_posts_category_id_idx").on(t.categoryId),
|
||||
],
|
||||
)
|
||||
|
||||
// --- Contatti, showroom, consensi ---
|
||||
export const contactRequests = pgTable(
|
||||
"contact_requests",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
phone: text("phone"),
|
||||
subject: text("subject"),
|
||||
message: text("message").notNull(),
|
||||
status: contactRequestStatus("status").notNull().default("new"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
lowercaseEmailCheck("contact_requests_email_lowercase", t.email),
|
||||
index("contact_requests_status_idx").on(t.status, t.createdAt),
|
||||
],
|
||||
)
|
||||
|
||||
export const showroomBookings = pgTable(
|
||||
"showroom_bookings",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "set null" }),
|
||||
name: text("name").notNull(),
|
||||
email: text("email").notNull(),
|
||||
phone: text("phone"),
|
||||
/** Inizio dello slot richiesto. */
|
||||
requestedAt: timestamp("requested_at", { withTimezone: true }).notNull(),
|
||||
people: smallint("people").notNull().default(1),
|
||||
notes: text("notes"),
|
||||
status: showroomBookingStatus("status").notNull().default("requested"),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
},
|
||||
(t) => [
|
||||
lowercaseEmailCheck("showroom_bookings_email_lowercase", t.email),
|
||||
index("showroom_bookings_requested_at_idx").on(t.requestedAt),
|
||||
check("showroom_bookings_people_positive", sql`${t.people} > 0`),
|
||||
],
|
||||
)
|
||||
|
||||
export const consents = pgTable(
|
||||
"consents",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id").references(() => users.id, { onDelete: "cascade" }),
|
||||
email: text("email"),
|
||||
type: consentType("type").notNull(),
|
||||
/** Versione del testo informativo accettato (es. "privacy-2026-01"). */
|
||||
version: text("version").notNull(),
|
||||
/** Form di provenienza (es. "register", "contact", "configurator"). */
|
||||
source: text("source"),
|
||||
acceptedAt: timestamp("accepted_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
ip: inet("ip"),
|
||||
},
|
||||
(t) => [
|
||||
check("consents_subject_present", sql`num_nonnulls(${t.userId}, ${t.email}) >= 1`),
|
||||
lowercaseEmailCheck("consents_email_lowercase", t.email),
|
||||
index("consents_user_id_idx").on(t.userId, t.type),
|
||||
index("consents_email_idx").on(t.email, t.type),
|
||||
],
|
||||
)
|
||||
|
||||
// --- Idempotenza webhook Medusa ---
|
||||
export const webhookEvents = pgTable("webhook_events", {
|
||||
/** id evento inviato da Medusa (UUID v5 deterministico): PK = idempotenza. */
|
||||
id: uuid("id").primaryKey(),
|
||||
event: text("event").notNull(),
|
||||
payload: jsonb("payload").notNull(),
|
||||
receivedAt: timestamp("received_at", { withTimezone: true }).notNull().defaultNow(),
|
||||
processedAt: timestamp("processed_at", { withTimezone: true }),
|
||||
error: text("error"),
|
||||
})
|
||||
|
||||
// --- Tipi riga ---
|
||||
export type InstallerProfile = typeof installerProfiles.$inferSelect
|
||||
export type NewInstallerProfile = typeof installerProfiles.$inferInsert
|
||||
export type Document = typeof documents.$inferSelect
|
||||
export type NewDocument = typeof documents.$inferInsert
|
||||
export type QuoteRequest = typeof quoteRequests.$inferSelect
|
||||
export type NewQuoteRequest = typeof quoteRequests.$inferInsert
|
||||
export type Quote = typeof quotes.$inferSelect
|
||||
export type NewQuote = typeof quotes.$inferInsert
|
||||
export type Ticket = typeof tickets.$inferSelect
|
||||
export type NewTicket = typeof tickets.$inferInsert
|
||||
export type TicketMessage = typeof ticketMessages.$inferSelect
|
||||
export type NewTicketMessage = typeof ticketMessages.$inferInsert
|
||||
export type BlogCategory = typeof blogCategories.$inferSelect
|
||||
export type NewBlogCategory = typeof blogCategories.$inferInsert
|
||||
export type BlogPost = typeof blogPosts.$inferSelect
|
||||
export type NewBlogPost = typeof blogPosts.$inferInsert
|
||||
export type ContactRequest = typeof contactRequests.$inferSelect
|
||||
export type NewContactRequest = typeof contactRequests.$inferInsert
|
||||
export type ShowroomBooking = typeof showroomBookings.$inferSelect
|
||||
export type NewShowroomBooking = typeof showroomBookings.$inferInsert
|
||||
export type Consent = typeof consents.$inferSelect
|
||||
export type NewConsent = typeof consents.$inferInsert
|
||||
export type WebhookEvent = typeof webhookEvents.$inferSelect
|
||||
export type NewWebhookEvent = typeof webhookEvents.$inferInsert
|
||||
@@ -0,0 +1,4 @@
|
||||
// Schema Drizzle di iti_app: ogni gruppo di tabelle in un file, riesportato qui
|
||||
// (usato dal client, dai servizi e da drizzle-kit per generare le migrazioni).
|
||||
export * from "./users.js"
|
||||
export * from "./domain.js"
|
||||
@@ -0,0 +1,90 @@
|
||||
import { USER_ROLES } from "@iti/shared"
|
||||
import { sql } from "drizzle-orm"
|
||||
import {
|
||||
check,
|
||||
index,
|
||||
inet,
|
||||
pgEnum,
|
||||
pgTable,
|
||||
text,
|
||||
timestamp,
|
||||
uniqueIndex,
|
||||
uuid,
|
||||
} from "drizzle-orm/pg-core"
|
||||
import { createdAt, updatedAt } from "./columns.js"
|
||||
|
||||
export const userRole = pgEnum("user_role", USER_ROLES)
|
||||
|
||||
export const users = pgTable(
|
||||
"users",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
/** Sempre minuscola (vincolo check); unicità case-insensitive via indice su lower(email). */
|
||||
email: text("email").notNull(),
|
||||
passwordHash: text("password_hash").notNull(),
|
||||
role: userRole("role").notNull().default("customer"),
|
||||
firstName: text("first_name").notNull(),
|
||||
lastName: text("last_name").notNull(),
|
||||
companyName: text("company_name"),
|
||||
vatNumber: text("vat_number"),
|
||||
/** Customer Medusa corrispondente (has_account=true), creato via POST /internal/customers. */
|
||||
medusaCustomerId: text("medusa_customer_id").unique(),
|
||||
emailVerifiedAt: timestamp("email_verified_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
updatedAt: updatedAt(),
|
||||
disabledAt: timestamp("disabled_at", { withTimezone: true }),
|
||||
},
|
||||
(t) => [
|
||||
uniqueIndex("users_email_lower_key").on(sql`lower(${t.email})`),
|
||||
check("users_email_lowercase", sql`${t.email} = lower(${t.email})`),
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* Refresh token opachi: in DB solo lo SHA-256 del token (token_hash), mai il valore.
|
||||
* family_id raggruppa le rotazioni dello stesso login (riuso di un token revocato → revoca della family).
|
||||
*/
|
||||
export const refreshTokens = pgTable(
|
||||
"refresh_tokens",
|
||||
{
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
familyId: uuid("family_id").notNull(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
revokedAt: timestamp("revoked_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
userAgent: text("user_agent"),
|
||||
ip: inet("ip"),
|
||||
},
|
||||
(t) => [index("refresh_tokens_user_id_idx").on(t.userId), index("refresh_tokens_family_id_idx").on(t.familyId)],
|
||||
)
|
||||
|
||||
/** Colonne comuni ai token monouso (reset password, verifica email): solo hash in DB. */
|
||||
const oneTimeTokenColumns = () => ({
|
||||
id: uuid("id").primaryKey().defaultRandom(),
|
||||
userId: uuid("user_id")
|
||||
.notNull()
|
||||
.references(() => users.id, { onDelete: "cascade" }),
|
||||
tokenHash: text("token_hash").notNull().unique(),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }).notNull(),
|
||||
usedAt: timestamp("used_at", { withTimezone: true }),
|
||||
createdAt: createdAt(),
|
||||
})
|
||||
|
||||
export const passwordResetTokens = pgTable("password_reset_tokens", oneTimeTokenColumns(), (t) => [
|
||||
index("password_reset_tokens_user_id_idx").on(t.userId),
|
||||
])
|
||||
|
||||
export const emailVerificationTokens = pgTable("email_verification_tokens", oneTimeTokenColumns(), (t) => [
|
||||
index("email_verification_tokens_user_id_idx").on(t.userId),
|
||||
])
|
||||
|
||||
export type User = typeof users.$inferSelect
|
||||
export type NewUser = typeof users.$inferInsert
|
||||
export type RefreshToken = typeof refreshTokens.$inferSelect
|
||||
export type NewRefreshToken = typeof refreshTokens.$inferInsert
|
||||
export type PasswordResetToken = typeof passwordResetTokens.$inferSelect
|
||||
export type EmailVerificationToken = typeof emailVerificationTokens.$inferSelect
|
||||
@@ -0,0 +1,50 @@
|
||||
import { sql } from "drizzle-orm"
|
||||
import { ConfigError, parseEnv, seedEnvSchema } from "../config/env.js"
|
||||
import { hashPassword } from "../services/password.js"
|
||||
import { createDb } from "./client.js"
|
||||
import { users } from "./schema/index.js"
|
||||
|
||||
/**
|
||||
* Seed idempotente: crea l'utente admin da SEED_ADMIN_EMAIL / SEED_ADMIN_PASSWORD.
|
||||
* Se l'email esiste già non modifica nulla (né password né ruolo).
|
||||
* Uso: `npm run db:seed -w apps/api` (dopo db:migrate).
|
||||
*/
|
||||
try {
|
||||
const env = parseEnv(seedEnvSchema)
|
||||
const { db, close } = createDb(env.DATABASE_URL, { max: 1 })
|
||||
try {
|
||||
const [existing] = await db
|
||||
.select({ id: users.id, role: users.role })
|
||||
.from(users)
|
||||
.where(sql`lower(${users.email}) = ${env.SEED_ADMIN_EMAIL}`)
|
||||
|
||||
if (existing) {
|
||||
const note = existing.role === "admin" ? "" : ` (ATTENZIONE: ruolo attuale "${existing.role}", non admin)`
|
||||
console.log(`[db:seed] admin ${env.SEED_ADMIN_EMAIL} già presente, nessuna modifica${note}`)
|
||||
} else {
|
||||
const [created] = await db
|
||||
.insert(users)
|
||||
.values({
|
||||
email: env.SEED_ADMIN_EMAIL,
|
||||
passwordHash: await hashPassword(env.SEED_ADMIN_PASSWORD),
|
||||
role: "admin",
|
||||
firstName: env.SEED_ADMIN_FIRST_NAME,
|
||||
lastName: env.SEED_ADMIN_LAST_NAME,
|
||||
// Creato da chi gestisce la piattaforma: email considerata verificata
|
||||
emailVerifiedAt: new Date(),
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning({ id: users.id })
|
||||
console.log(
|
||||
created
|
||||
? `[db:seed] admin ${env.SEED_ADMIN_EMAIL} creato (${created.id})`
|
||||
: `[db:seed] admin ${env.SEED_ADMIN_EMAIL} già presente, nessuna modifica`,
|
||||
)
|
||||
}
|
||||
} finally {
|
||||
await close()
|
||||
}
|
||||
} catch (err) {
|
||||
console.error(err instanceof ConfigError ? `[db:seed] ${err.message}` : err)
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { html, oneLine } from "./html.js"
|
||||
import { button, layout, paragraph, type RenderedEmail } from "./layout.js"
|
||||
|
||||
export type VerifyEmailData = { firstName: string; link: string }
|
||||
export type ResetPasswordData = { firstName: string; link: string }
|
||||
export type RegisterExistingData = { firstName: string; forgotPasswordUrl: string }
|
||||
|
||||
export function verifyEmail(d: VerifyEmailData): RenderedEmail {
|
||||
return layout({
|
||||
subject: oneLine("Conferma il tuo indirizzo email - ITI TECHNOLOGY"),
|
||||
preheader: "Conferma il tuo indirizzo per completare la registrazione",
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph("conferma il tuo indirizzo email con il pulsante qui sotto. Il link è valido 48 ore.")}
|
||||
${button("Conferma email", d.link)}
|
||||
${paragraph("Se non hai creato tu l'account, ignora questo messaggio.")}`,
|
||||
text: `Ciao ${d.firstName},\n\nconferma il tuo indirizzo email aprendo questo link (valido 48 ore):\n${d.link}\n\nSe non hai creato tu l'account, ignora questo messaggio.`,
|
||||
})
|
||||
}
|
||||
|
||||
export function resetPassword(d: ResetPasswordData): RenderedEmail {
|
||||
return layout({
|
||||
subject: oneLine("Reimposta la password - ITI TECHNOLOGY"),
|
||||
preheader: "Scegli una nuova password per il tuo account",
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph("per scegliere una nuova password usa il pulsante qui sotto. Il link è valido 1 ora.")}
|
||||
${button("Reimposta password", d.link)}
|
||||
${paragraph("Se non hai richiesto tu il reset, ignora questo messaggio: la password attuale resta valida.")}`,
|
||||
text: `Ciao ${d.firstName},\n\nper scegliere una nuova password apri questo link (valido 1 ora):\n${d.link}\n\nSe non hai richiesto tu il reset, ignora questo messaggio: la password attuale resta valida.`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Avviso al titolare quando qualcuno prova a registrarsi con la sua email (anti-enumerazione). */
|
||||
export function registerExisting(d: RegisterExistingData): RenderedEmail {
|
||||
return layout({
|
||||
subject: oneLine("Tentativo di registrazione con il tuo indirizzo - ITI TECHNOLOGY"),
|
||||
preheader: "Il tuo indirizzo è già registrato",
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph("qualcuno ha provato a creare un account con questo indirizzo, che è già registrato.")}
|
||||
${paragraph("Se hai dimenticato la password puoi reimpostarla:")}
|
||||
${button("Reimposta password", d.forgotPasswordUrl)}
|
||||
${paragraph("Se non sei stato tu, ignora questo messaggio.")}`,
|
||||
text: `Ciao ${d.firstName},\n\nqualcuno ha provato a creare un account con questo indirizzo, che è già registrato.\nSe hai dimenticato la password puoi reimpostarla da: ${d.forgotPasswordUrl}\n\nSe non sei stato tu, ignora questo messaggio.`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { formatCents, formatDate, formatDateTime, html, oneLine } from "./html.js"
|
||||
import { button, layout, paragraph, type RenderedEmail, table } from "./layout.js"
|
||||
|
||||
export type QuoteRequestAdminData = {
|
||||
name: string
|
||||
email: string
|
||||
phone: string | null
|
||||
province: string | null
|
||||
/** Sintesi leggibile della richiesta (es. risposte del configuratore): coppie etichetta/valore. */
|
||||
summary: { label: string; value: string }[]
|
||||
adminUrl: string
|
||||
}
|
||||
|
||||
/** Conferma all'utente della richiesta inviata dal configuratore. */
|
||||
export type QuoteRequestReceivedData = {
|
||||
name: string
|
||||
solutionTitle: string
|
||||
summary: { label: string; value: string }[]
|
||||
disclaimer: string
|
||||
}
|
||||
|
||||
export type QuoteSentData = {
|
||||
firstName: string
|
||||
quoteNumber: string
|
||||
title: string
|
||||
amountCents: number
|
||||
/** Data di scadenza (ISO) o null. */
|
||||
validUntil: string | null
|
||||
link: string
|
||||
}
|
||||
|
||||
export type InstallerApprovedData = { firstName: string; businessName: string; link: string }
|
||||
export type InstallerRejectedData = { firstName: string; businessName: string; reason: string | null }
|
||||
|
||||
/** Profilo installatore approvato: accesso a listino riservato e documentazione tecnica. */
|
||||
export function installerApproved(d: InstallerApprovedData): RenderedEmail {
|
||||
const what = "da ora vedi i prezzi del listino riservato agli installatori e puoi scaricare la documentazione tecnica dall'area riservata."
|
||||
return layout({
|
||||
subject: oneLine("Account installatore approvato - ITI TECHNOLOGY"),
|
||||
preheader: "Il tuo profilo installatore è stato approvato",
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph(html`il profilo installatore di <strong>${d.businessName}</strong> è stato approvato: ${what}`)}
|
||||
${button("Vai all'area installatori", d.link)}
|
||||
${paragraph("Se eri già collegato, esci e rientra per vedere subito i prezzi aggiornati.")}`,
|
||||
text: `Ciao ${d.firstName},\n\nil profilo installatore di ${d.businessName} è stato approvato: ${what}\n\nArea installatori: ${d.link}\n\nSe eri già collegato, esci e rientra per vedere subito i prezzi aggiornati.`,
|
||||
})
|
||||
}
|
||||
|
||||
export function installerRejected(d: InstallerRejectedData): RenderedEmail {
|
||||
const outro = "Per chiarimenti o per inviare documentazione aggiuntiva rispondi a questa email."
|
||||
return layout({
|
||||
subject: oneLine("Richiesta account installatore - ITI TECHNOLOGY"),
|
||||
preheader: "Esito della richiesta di account installatore",
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph(html`al momento non possiamo approvare il profilo installatore di <strong>${d.businessName}</strong>.`)}
|
||||
${d.reason ? paragraph(html`Motivo: ${d.reason}`) : ""}
|
||||
${paragraph("Puoi comunque usare il tuo account come cliente.")}
|
||||
${paragraph(outro)}`,
|
||||
text:
|
||||
`Ciao ${d.firstName},\n\nal momento non possiamo approvare il profilo installatore di ${d.businessName}.` +
|
||||
(d.reason ? `\nMotivo: ${d.reason}` : "") +
|
||||
`\n\nPuoi comunque usare il tuo account come cliente.\n${outro}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Notifica generica allo staff (nuovo ticket, risposta del cliente, preventivo accettato/rifiutato). */
|
||||
export type StaffNotificationData = { title: string; intro: string; rows: { label: string; value: string }[]; url: string }
|
||||
|
||||
export type TicketReplyData = { firstName: string; ticketSubject: string; message: string; link: string }
|
||||
|
||||
export type ShowroomBookingConfirmedData = {
|
||||
name: string
|
||||
/** Inizio dello slot (ISO). */
|
||||
requestedAt: string
|
||||
people: number
|
||||
notes: string | null
|
||||
}
|
||||
|
||||
/** Nuova richiesta di preventivo (notifica interna). */
|
||||
export function quoteRequestAdmin(d: QuoteRequestAdminData): RenderedEmail {
|
||||
const contact: [string, string][] = [
|
||||
["Nome", d.name],
|
||||
["Email", d.email],
|
||||
["Telefono", d.phone ?? "-"],
|
||||
["Provincia", d.province ?? "-"],
|
||||
]
|
||||
return layout({
|
||||
subject: oneLine(`Nuova richiesta di preventivo - ${d.name}`),
|
||||
preheader: `Richiesta da ${d.email}`,
|
||||
body: html`${paragraph("È arrivata una nuova richiesta di preventivo.")}
|
||||
${table(contact)}
|
||||
${d.summary.length ? table(d.summary.map((s) => [s.label, s.value] as const)) : ""}
|
||||
${button("Apri nel pannello admin", d.adminUrl)}`,
|
||||
text:
|
||||
`È arrivata una nuova richiesta di preventivo.\n\n${contact.map(([k, v]) => `${k}: ${v}`).join("\n")}` +
|
||||
(d.summary.length ? `\n\n${d.summary.map((s) => `${s.label}: ${s.value}`).join("\n")}` : "") +
|
||||
`\n\nApri nel pannello admin: ${d.adminUrl}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function quoteRequestReceived(d: QuoteRequestReceivedData): RenderedEmail {
|
||||
const intro = "abbiamo ricevuto la tua richiesta: un nostro tecnico ti contatterà per verificare i dati e preparare il preventivo."
|
||||
return layout({
|
||||
subject: oneLine("Abbiamo ricevuto la tua richiesta - ITI TECHNOLOGY"),
|
||||
preheader: `Soluzione indicativa: ${d.solutionTitle}`,
|
||||
body: html`${paragraph(html`Ciao ${d.name},`)}
|
||||
${paragraph(intro)}
|
||||
${paragraph(html`Soluzione indicativa: <strong>${d.solutionTitle}</strong>`)}
|
||||
${d.summary.length ? table(d.summary.map((s) => [s.label, s.value] as const)) : ""}
|
||||
<p style="margin:0 0 16px;color:#6b7280;font-size:13px">${d.disclaimer}</p>`,
|
||||
text:
|
||||
`Ciao ${d.name},\n\n${intro}\n\nSoluzione indicativa: ${d.solutionTitle}` +
|
||||
(d.summary.length ? `\n\n${d.summary.map((s) => `${s.label}: ${s.value}`).join("\n")}` : "") +
|
||||
`\n\n${d.disclaimer}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Preventivo inviato al cliente. */
|
||||
export function quoteSent(d: QuoteSentData): RenderedEmail {
|
||||
const rows: [string, string][] = [
|
||||
["Preventivo", d.quoteNumber],
|
||||
["Oggetto", d.title],
|
||||
["Importo (IVA inclusa)", formatCents(d.amountCents)],
|
||||
]
|
||||
if (d.validUntil) rows.push(["Valido fino al", formatDate(d.validUntil)])
|
||||
return layout({
|
||||
subject: oneLine(`Preventivo ${d.quoteNumber} - ITI TECHNOLOGY`),
|
||||
preheader: `Il preventivo ${d.quoteNumber} è disponibile`,
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph("abbiamo preparato il tuo preventivo.")}
|
||||
${table(rows)}
|
||||
${button("Visualizza il preventivo", d.link)}
|
||||
${paragraph("Per domande o modifiche rispondi a questa email.")}`,
|
||||
text: `Ciao ${d.firstName},\n\nabbiamo preparato il tuo preventivo.\n\n${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\nVisualizzalo nell'area clienti: ${d.link}\n\nPer domande o modifiche rispondi a questa email.`,
|
||||
})
|
||||
}
|
||||
|
||||
export function staffNotification(d: StaffNotificationData): RenderedEmail {
|
||||
return layout({
|
||||
subject: oneLine(`[ITI] ${d.title}`),
|
||||
preheader: d.intro,
|
||||
body: html`${paragraph(d.intro)}
|
||||
${d.rows.length ? table(d.rows.map((r) => [r.label, r.value] as const)) : ""}
|
||||
${button("Apri nel pannello admin", d.url)}`,
|
||||
text: `${d.intro}\n\n${d.rows.map((r) => `${r.label}: ${r.value}`).join("\n")}\n\nApri nel pannello admin: ${d.url}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Nuova risposta a un ticket di assistenza. */
|
||||
export function ticketReply(d: TicketReplyData): RenderedEmail {
|
||||
return layout({
|
||||
subject: oneLine(`Risposta alla richiesta "${d.ticketSubject}" - ITI TECHNOLOGY`),
|
||||
preheader: "C'è una nuova risposta alla tua richiesta di assistenza",
|
||||
body: html`${paragraph(html`Ciao ${d.firstName},`)}
|
||||
${paragraph(html`c'è una nuova risposta alla tua richiesta <strong>${d.ticketSubject}</strong>:`)}
|
||||
<blockquote style="margin:0 0 16px;padding:12px 16px;border-left:4px solid #0b5c7a;background:#f9fafb;white-space:pre-wrap">${d.message}</blockquote>
|
||||
${button("Rispondi", d.link)}`,
|
||||
text: `Ciao ${d.firstName},\n\nc'è una nuova risposta alla tua richiesta "${d.ticketSubject}":\n\n${d.message}\n\nPer rispondere: ${d.link}`,
|
||||
})
|
||||
}
|
||||
|
||||
/** Conferma della prenotazione di una visita allo showroom. */
|
||||
export function showroomBookingConfirmed(d: ShowroomBookingConfirmedData): RenderedEmail {
|
||||
const rows: [string, string][] = [
|
||||
["Data e ora", formatDateTime(d.requestedAt)],
|
||||
["Persone", String(d.people)],
|
||||
]
|
||||
if (d.notes) rows.push(["Note", d.notes])
|
||||
return layout({
|
||||
subject: oneLine("Visita allo showroom confermata - ITI TECHNOLOGY"),
|
||||
preheader: `Ti aspettiamo ${formatDateTime(d.requestedAt)}`,
|
||||
body: html`${paragraph(html`Ciao ${d.name},`)}
|
||||
${paragraph("la tua visita allo showroom è confermata.")}
|
||||
${table(rows)}
|
||||
${paragraph("Se non puoi più venire, rispondi a questa email così liberiamo lo slot.")}`,
|
||||
text: `Ciao ${d.name},\n\nla tua visita allo showroom è confermata.\n\n${rows.map(([k, v]) => `${k}: ${v}`).join("\n")}\n\nSe non puoi più venire, rispondi a questa email così liberiamo lo slot.`,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* Mini-helper per HTML sicuro nelle email: ogni valore interpolato con il tag `html` viene
|
||||
* escapato, tranne i frammenti già sicuri (SafeHtml, prodotti da `html` stesso).
|
||||
*/
|
||||
export class SafeHtml {
|
||||
constructor(readonly value: string) {}
|
||||
toString() {
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
|
||||
const ESCAPES: Record<string, string> = { "&": "&", "<": "<", ">": ">", '"': """, "'": "'" }
|
||||
|
||||
/** Escape per contenuto e attributi HTML. */
|
||||
export const escapeHtml = (value: unknown) => String(value ?? "").replace(/[&<>"']/g, (c) => ESCAPES[c] ?? c)
|
||||
|
||||
type HtmlValue = SafeHtml | string | number | null | undefined | false | readonly HtmlValue[]
|
||||
|
||||
function render(value: HtmlValue): string {
|
||||
if (value === null || value === undefined || value === false) return ""
|
||||
if (value instanceof SafeHtml) return value.value
|
||||
if (Array.isArray(value)) return value.map(render).join("")
|
||||
return escapeHtml(value)
|
||||
}
|
||||
|
||||
/** Template literal con escaping automatico: html`<p>${nome}</p>`. */
|
||||
export function html(strings: TemplateStringsArray, ...values: HtmlValue[]): SafeHtml {
|
||||
let out = strings[0] ?? ""
|
||||
values.forEach((v, i) => {
|
||||
out += render(v) + (strings[i + 1] ?? "")
|
||||
})
|
||||
return new SafeHtml(out)
|
||||
}
|
||||
|
||||
/** Solo URL http(s): un valore diverso (es. "javascript:") diventa "#". */
|
||||
export function safeUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url)
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:" ? parsed.toString() : "#"
|
||||
} catch {
|
||||
return "#"
|
||||
}
|
||||
}
|
||||
|
||||
/** Oggetto email su una riga (niente a capo: evita header injection e oggetti spezzati). */
|
||||
export const oneLine = (value: string) => value.replace(/[\r\n]+/g, " ").trim()
|
||||
|
||||
/**
|
||||
* Centesimi → "1.190,00 €". useGrouping "always": per it-IT il CLDR non separa le migliaia sotto le
|
||||
* 5 cifre; il valore stringa è ES2023 (Node 24), i tipi della lib ES2022 ammettono solo boolean.
|
||||
*/
|
||||
const euro = new Intl.NumberFormat("it-IT", {
|
||||
style: "currency",
|
||||
currency: "EUR",
|
||||
useGrouping: "always" as unknown as boolean,
|
||||
})
|
||||
export const formatCents = (cents: number) => euro.format(cents / 100)
|
||||
|
||||
const dateTime = new Intl.DateTimeFormat("it-IT", {
|
||||
dateStyle: "full",
|
||||
timeStyle: "short",
|
||||
timeZone: "Europe/Rome",
|
||||
})
|
||||
const dateOnly = new Intl.DateTimeFormat("it-IT", { dateStyle: "long", timeZone: "Europe/Rome" })
|
||||
|
||||
/** Data e ora in italiano, fuso Europe/Rome: "sabato 10 ottobre 2026 alle ore 15:00". */
|
||||
export const formatDateTime = (iso: string) => dateTime.format(new Date(iso))
|
||||
export const formatDate = (iso: string) => dateOnly.format(new Date(iso))
|
||||
@@ -0,0 +1,78 @@
|
||||
import { registerExisting, type RegisterExistingData, resetPassword, type ResetPasswordData, verifyEmail, type VerifyEmailData } from "./auth.js"
|
||||
import {
|
||||
installerApproved,
|
||||
type InstallerApprovedData,
|
||||
installerRejected,
|
||||
type InstallerRejectedData,
|
||||
quoteRequestAdmin,
|
||||
type QuoteRequestAdminData,
|
||||
quoteRequestReceived,
|
||||
type QuoteRequestReceivedData,
|
||||
quoteSent,
|
||||
type QuoteSentData,
|
||||
showroomBookingConfirmed,
|
||||
type ShowroomBookingConfirmedData,
|
||||
staffNotification,
|
||||
type StaffNotificationData,
|
||||
ticketReply,
|
||||
type TicketReplyData,
|
||||
} from "./business.js"
|
||||
import type { RenderedEmail } from "./layout.js"
|
||||
import {
|
||||
orderCanceled,
|
||||
type OrderCanceledData,
|
||||
orderConfirmation,
|
||||
type OrderConfirmationData,
|
||||
orderPlacedAdmin,
|
||||
type OrderPlacedAdminData,
|
||||
orderShipped,
|
||||
type OrderShippedData,
|
||||
} from "./orders.js"
|
||||
|
||||
export type { RenderedEmail } from "./layout.js"
|
||||
export type { OrderEmailData } from "./orders.js"
|
||||
|
||||
/** Dati richiesti da ogni template (il nome è anche il tag inviato al provider). */
|
||||
export type EmailTemplates = {
|
||||
"verify-email": VerifyEmailData
|
||||
"reset-password": ResetPasswordData
|
||||
"register-existing": RegisterExistingData
|
||||
"order-confirmation": OrderConfirmationData
|
||||
"order-placed-admin": OrderPlacedAdminData
|
||||
"order-canceled": OrderCanceledData
|
||||
"order-shipped": OrderShippedData
|
||||
"quote-request-admin": QuoteRequestAdminData
|
||||
"quote-request-received": QuoteRequestReceivedData
|
||||
"quote-sent": QuoteSentData
|
||||
"ticket-reply": TicketReplyData
|
||||
"showroom-booking-confirmed": ShowroomBookingConfirmedData
|
||||
"staff-notification": StaffNotificationData
|
||||
"installer-approved": InstallerApprovedData
|
||||
"installer-rejected": InstallerRejectedData
|
||||
}
|
||||
export type EmailTemplate = keyof EmailTemplates
|
||||
|
||||
const renderers: { [K in EmailTemplate]: (data: EmailTemplates[K]) => RenderedEmail } = {
|
||||
"verify-email": verifyEmail,
|
||||
"reset-password": resetPassword,
|
||||
"register-existing": registerExisting,
|
||||
"order-confirmation": orderConfirmation,
|
||||
"order-placed-admin": orderPlacedAdmin,
|
||||
"order-canceled": orderCanceled,
|
||||
"order-shipped": orderShipped,
|
||||
"quote-request-admin": quoteRequestAdmin,
|
||||
"quote-request-received": quoteRequestReceived,
|
||||
"quote-sent": quoteSent,
|
||||
"ticket-reply": ticketReply,
|
||||
"showroom-booking-confirmed": showroomBookingConfirmed,
|
||||
"staff-notification": staffNotification,
|
||||
"installer-approved": installerApproved,
|
||||
"installer-rejected": installerRejected,
|
||||
}
|
||||
|
||||
export const EMAIL_TEMPLATES = Object.keys(renderers) as EmailTemplate[]
|
||||
|
||||
/** Renderizza un template (oggetto, HTML con layout comune, testo). */
|
||||
export function renderEmail<T extends EmailTemplate>(template: T, data: EmailTemplates[T]): RenderedEmail {
|
||||
return renderers[template](data)
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { html, SafeHtml, safeUrl } from "./html.js"
|
||||
|
||||
/** Email renderizzata: oggetto, HTML (layout comune) e testo semplice. */
|
||||
export type RenderedEmail = { subject: string; html: string; text: string }
|
||||
|
||||
const BRAND = "ITI TECHNOLOGY"
|
||||
const COLOR = "#0b5c7a"
|
||||
const FOOTER_TEXT = `${BRAND} · trattamento acqua, energia rinnovabile, comfort e mobilità elettrica`
|
||||
|
||||
/** Pulsante (link) in stile brand; l'URL passa da safeUrl. */
|
||||
export const button = (label: string, url: string) => html`<table role="presentation" cellpadding="0" cellspacing="0" style="margin:24px 0">
|
||||
<tr><td style="background:${COLOR};border-radius:6px">
|
||||
<a href="${safeUrl(url)}" style="display:inline-block;padding:12px 24px;color:#ffffff;text-decoration:none;font-weight:bold">${label}</a>
|
||||
</td></tr>
|
||||
</table>`
|
||||
|
||||
export const paragraph = (content: SafeHtml | string) => html`<p style="margin:0 0 16px">${content}</p>`
|
||||
|
||||
/** Tabella chiave/valore (riepiloghi d'ordine, preventivi, prenotazioni). */
|
||||
export function table(rows: readonly (readonly [string, SafeHtml | string])[], opts: { boldLast?: boolean } = {}) {
|
||||
return html`<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="border-collapse:collapse;margin:0 0 16px">
|
||||
${rows.map(
|
||||
([label, value], i) =>
|
||||
html`<tr>
|
||||
<td style="padding:6px 0;border-bottom:1px solid #e5e7eb${opts.boldLast && i === rows.length - 1 ? ";font-weight:bold" : ""}">${label}</td>
|
||||
<td style="padding:6px 0;border-bottom:1px solid #e5e7eb;text-align:right${opts.boldLast && i === rows.length - 1 ? ";font-weight:bold" : ""}">${value}</td>
|
||||
</tr>`,
|
||||
)}
|
||||
</table>`
|
||||
}
|
||||
|
||||
/**
|
||||
* Layout comune: intestazione brand, contenuto, piè di pagina. HTML semplice a tabelle con stili
|
||||
* inline (compatibile con i client di posta). `preheader` = anteprima mostrata nella lista messaggi.
|
||||
*/
|
||||
export function layout(opts: { subject: string; preheader: string; body: SafeHtml; text: string }): RenderedEmail {
|
||||
const page = html`<!doctype html>
|
||||
<html lang="it">
|
||||
<head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>${opts.subject}</title></head>
|
||||
<body style="margin:0;padding:0;background:#f3f4f6;font-family:Arial,Helvetica,sans-serif;color:#111827;line-height:1.5">
|
||||
<span style="display:none;max-height:0;overflow:hidden;opacity:0">${opts.preheader}</span>
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="100%" style="background:#f3f4f6;padding:24px 0">
|
||||
<tr><td align="center">
|
||||
<table role="presentation" cellpadding="0" cellspacing="0" width="600" style="max-width:600px;width:100%;background:#ffffff;border-radius:8px;overflow:hidden">
|
||||
<tr><td style="background:${COLOR};padding:20px 32px;color:#ffffff;font-size:20px;font-weight:bold;letter-spacing:1px">${BRAND}</td></tr>
|
||||
<tr><td style="padding:32px">${opts.body}</td></tr>
|
||||
<tr><td style="padding:16px 32px;background:#f9fafb;color:#6b7280;font-size:12px">${FOOTER_TEXT}<br>Questa è un'email automatica.</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
</table>
|
||||
</body>
|
||||
</html>`
|
||||
return {
|
||||
subject: opts.subject,
|
||||
html: page.value,
|
||||
text: `${opts.text.trim()}\n\n--\n${FOOTER_TEXT}\n`,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { formatCents, html, oneLine } from "./html.js"
|
||||
import { button, layout, paragraph, type RenderedEmail, table } from "./layout.js"
|
||||
|
||||
/** Dati d'ordine per le email (indipendenti da Medusa). Importi in centesimi IVA inclusa. */
|
||||
export type OrderEmailData = {
|
||||
number: number
|
||||
email: string
|
||||
customerName: string
|
||||
guest: boolean
|
||||
items: { name: string; quantity: number; total: number }[]
|
||||
itemTotal: number
|
||||
shippingTotal: number
|
||||
discountTotal: number
|
||||
taxTotal: number
|
||||
total: number
|
||||
/** Righe dell'indirizzo di spedizione (vuoto se assente). */
|
||||
shippingAddress: string[]
|
||||
}
|
||||
|
||||
export type OrderConfirmationData = { order: OrderEmailData; ordersUrl: string }
|
||||
export type OrderPlacedAdminData = { order: OrderEmailData }
|
||||
export type OrderCanceledData = { order: OrderEmailData }
|
||||
export type OrderShippedData = { order: OrderEmailData; tracking: { number: string | null; url: string | null }[] }
|
||||
|
||||
const SIGN = "\n\nGrazie,\nITI TECHNOLOGY"
|
||||
|
||||
function itemsText(o: OrderEmailData) {
|
||||
return o.items.map((i) => `- ${i.quantity} x ${i.name}: ${formatCents(i.total)}`).join("\n")
|
||||
}
|
||||
|
||||
function totalsRows(o: OrderEmailData) {
|
||||
const rows: [string, string][] = [
|
||||
["Prodotti", formatCents(o.itemTotal)],
|
||||
["Spedizione", formatCents(o.shippingTotal)],
|
||||
]
|
||||
if (o.discountTotal) rows.push(["Sconti", `-${formatCents(o.discountTotal)}`])
|
||||
rows.push([`Totale (di cui IVA ${formatCents(o.taxTotal)})`, formatCents(o.total)])
|
||||
return rows
|
||||
}
|
||||
|
||||
const totalsText = (o: OrderEmailData) => totalsRows(o).map(([k, v]) => `${k}: ${v}`).join("\n")
|
||||
|
||||
function summaryHtml(o: OrderEmailData) {
|
||||
return html`${table(o.items.map((i) => [`${i.quantity} × ${i.name}`, formatCents(i.total)] as const))}
|
||||
${table(totalsRows(o), { boldLast: true })}
|
||||
${o.shippingAddress.length
|
||||
? html`<p style="margin:0 0 16px"><strong>Spedizione a</strong><br>${o.shippingAddress.map((l, i) => html`${i ? html`<br>` : ""}${l}`)}</p>`
|
||||
: ""}`
|
||||
}
|
||||
|
||||
const addressText = (o: OrderEmailData, label: string) =>
|
||||
o.shippingAddress.length ? `\n\n${label}:\n${o.shippingAddress.join("\n")}` : ""
|
||||
|
||||
export function orderConfirmation(d: OrderConfirmationData): RenderedEmail {
|
||||
const o = d.order
|
||||
return layout({
|
||||
subject: oneLine(`Conferma ordine n. ${o.number} - ITI TECHNOLOGY`),
|
||||
preheader: `Abbiamo ricevuto il tuo ordine n. ${o.number}`,
|
||||
body: html`${paragraph(html`Ciao ${o.customerName},`)}
|
||||
${paragraph(html`abbiamo ricevuto il tuo ordine <strong>n. ${o.number}</strong>.`)}
|
||||
${summaryHtml(o)}
|
||||
${paragraph("Ti scriveremo quando l'ordine verrà spedito.")}
|
||||
${o.guest ? "" : button("Vedi i tuoi ordini", d.ordersUrl)}`,
|
||||
text:
|
||||
`Ciao ${o.customerName},\n\nabbiamo ricevuto il tuo ordine n. ${o.number}.\n\n${itemsText(o)}\n\n${totalsText(o)}` +
|
||||
addressText(o, "Spedizione a") +
|
||||
`\n\nTi scriveremo quando l'ordine verrà spedito.` +
|
||||
(o.guest ? "" : ` Puoi seguirlo dall'area clienti: ${d.ordersUrl}`) +
|
||||
SIGN,
|
||||
})
|
||||
}
|
||||
|
||||
/** Notifica interna di nuovo ordine. */
|
||||
export function orderPlacedAdmin(d: OrderPlacedAdminData): RenderedEmail {
|
||||
const o = d.order
|
||||
return layout({
|
||||
subject: oneLine(`Nuovo ordine n. ${o.number} - ${formatCents(o.total)}`),
|
||||
preheader: `Ordine di ${o.email}`,
|
||||
body: html`${paragraph(html`Nuovo ordine <strong>n. ${o.number}</strong> da ${o.email}${o.guest ? " (guest)" : ""}.`)}
|
||||
${summaryHtml(o)}
|
||||
${paragraph("Gestione dal dashboard Medusa.")}`,
|
||||
text:
|
||||
`Nuovo ordine n. ${o.number} da ${o.email}${o.guest ? " (guest)" : ""}.\n\n${itemsText(o)}\n\n${totalsText(o)}` +
|
||||
addressText(o, "Spedizione a") +
|
||||
`\n\nGestione dal dashboard Medusa.`,
|
||||
})
|
||||
}
|
||||
|
||||
export function orderCanceled(d: OrderCanceledData): RenderedEmail {
|
||||
const o = d.order
|
||||
const refund =
|
||||
"Se il pagamento era già stato addebitato riceverai il rimborso sullo stesso metodo di pagamento. Per qualsiasi domanda rispondi a questa email."
|
||||
return layout({
|
||||
subject: oneLine(`Ordine n. ${o.number} annullato - ITI TECHNOLOGY`),
|
||||
preheader: `Il tuo ordine n. ${o.number} è stato annullato`,
|
||||
body: html`${paragraph(html`Ciao ${o.customerName},`)}
|
||||
${paragraph(html`il tuo ordine <strong>n. ${o.number}</strong> è stato annullato.`)}
|
||||
${table(o.items.map((i) => [`${i.quantity} × ${i.name}`, formatCents(i.total)] as const))}
|
||||
${paragraph(refund)}`,
|
||||
text: `Ciao ${o.customerName},\n\nil tuo ordine n. ${o.number} è stato annullato.\n\n${itemsText(o)}\n\n${refund}${SIGN}`,
|
||||
})
|
||||
}
|
||||
|
||||
export function orderShipped(d: OrderShippedData): RenderedEmail {
|
||||
const o = d.order
|
||||
const tracking = d.tracking.filter((t) => t.number || t.url)
|
||||
return layout({
|
||||
subject: oneLine(`Ordine n. ${o.number} spedito - ITI TECHNOLOGY`),
|
||||
preheader: `Il tuo ordine n. ${o.number} è in spedizione`,
|
||||
body: html`${paragraph(html`Ciao ${o.customerName},`)}
|
||||
${paragraph(html`il tuo ordine <strong>n. ${o.number}</strong> è in spedizione.`)}
|
||||
${tracking.map((t) => (t.url ? button(t.number ? `Traccia ${t.number}` : "Traccia la spedizione", t.url) : paragraph(html`Tracciamento: <strong>${t.number}</strong>`)))}
|
||||
${o.shippingAddress.length
|
||||
? html`<p style="margin:0 0 16px"><strong>Indirizzo di consegna</strong><br>${o.shippingAddress.map((l, i) => html`${i ? html`<br>` : ""}${l}`)}</p>`
|
||||
: ""}`,
|
||||
text:
|
||||
`Ciao ${o.customerName},\n\nil tuo ordine n. ${o.number} è in spedizione.` +
|
||||
(tracking.length ? `\n\nTracciamento:\n${tracking.map((t) => `- ${[t.number, t.url].filter(Boolean).join(" - ")}`).join("\n")}` : "") +
|
||||
addressText(o, "Indirizzo di consegna") +
|
||||
SIGN,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Anteprima di tutti i template: npm run email:preview -w apps/api [-- destinatario@example.com]
|
||||
* Li invia con il trasporto configurato (EMAIL_PROVIDER). Con "console" li logga e, se
|
||||
* EMAIL_OUTBOX_DIR è impostata, salva .txt e .html da aprire nel browser.
|
||||
* Con brevo/resend invia davvero: usare un destinatario proprio.
|
||||
*/
|
||||
import pino from "pino"
|
||||
import { loadConfig } from "../config/env.js"
|
||||
import { createEmailTransport, EmailSender } from "../services/email.js"
|
||||
import { EMAIL_TEMPLATES, type EmailTemplate, type EmailTemplates } from "./index.js"
|
||||
|
||||
const order = {
|
||||
number: 1042,
|
||||
email: "mario.rossi@example.com",
|
||||
customerName: "Mario",
|
||||
guest: false,
|
||||
items: [
|
||||
{ name: "Addolcitore 30 L - Standard", quantity: 1, total: 119000 },
|
||||
{ name: "Cartuccia filtro - Confezione da 3", quantity: 2, total: 5980 },
|
||||
],
|
||||
itemTotal: 124980,
|
||||
shippingTotal: 1500,
|
||||
discountTotal: 0,
|
||||
taxTotal: 22808,
|
||||
total: 126480,
|
||||
shippingAddress: ["Mario Rossi", "Via Roma 1", "20100 Milano (MI)"],
|
||||
}
|
||||
|
||||
const samples: { [K in EmailTemplate]: EmailTemplates[K] } = {
|
||||
"verify-email": { firstName: "Mario", link: "http://localhost:4300/verify-email?token=anteprima" },
|
||||
"reset-password": { firstName: "Mario", link: "http://localhost:4300/reset-password?token=anteprima" },
|
||||
"register-existing": { firstName: "Mario", forgotPasswordUrl: "http://localhost:4300/forgot-password" },
|
||||
"order-confirmation": { order, ordersUrl: "http://localhost:4300/ordini" },
|
||||
"order-placed-admin": { order },
|
||||
"order-canceled": { order },
|
||||
"order-shipped": { order, tracking: [{ number: "BRT123456", url: "https://tracking.example/BRT123456" }] },
|
||||
"quote-request-admin": {
|
||||
name: "Giulia Bianchi",
|
||||
email: "giulia@example.com",
|
||||
phone: "333 1234567",
|
||||
province: "TO",
|
||||
summary: [
|
||||
{ label: "Soluzione", value: "Fotovoltaico + accumulo" },
|
||||
{ label: "Consumo annuo", value: "4.500 kWh" },
|
||||
],
|
||||
adminUrl: "http://localhost:4300/admin/preventivi",
|
||||
},
|
||||
"quote-request-received": {
|
||||
name: "Giulia",
|
||||
solutionTitle: "Fotovoltaico 4,5 kWp con accumulo 10 kWh",
|
||||
summary: [
|
||||
{ label: "Investimento indicativo", value: "11.100 €" },
|
||||
{ label: "Risparmio annuo stimato", value: "1.150 €" },
|
||||
],
|
||||
disclaimer: "Stima indicativa basata su valori medi: non costituisce un'offerta.",
|
||||
},
|
||||
"quote-sent": {
|
||||
firstName: "Giulia",
|
||||
quoteNumber: "2026-0042",
|
||||
title: "Impianto fotovoltaico 6 kW con accumulo",
|
||||
amountCents: 1290000,
|
||||
validUntil: "2026-11-30",
|
||||
link: "http://localhost:4300/preventivi",
|
||||
},
|
||||
"ticket-reply": {
|
||||
firstName: "Giulia",
|
||||
ticketSubject: "Addolcitore in allarme",
|
||||
message: "Buongiorno,\nil tecnico passerà giovedì mattina.\nCordiali saluti",
|
||||
link: "http://localhost:4300/assistenza",
|
||||
},
|
||||
"staff-notification": {
|
||||
title: "Preventivo 2026-0042 accettato",
|
||||
intro: "Il cliente ha accettato il preventivo.",
|
||||
rows: [
|
||||
{ label: "Cliente", value: "Giulia Bianchi (giulia@example.com)" },
|
||||
{ label: "Importo", value: "12.900,00 €" },
|
||||
],
|
||||
url: "http://localhost:4300/admin/preventivi",
|
||||
},
|
||||
"installer-approved": { firstName: "Paolo", businessName: "Impianti Verdi Srl", link: "http://localhost:4300/installatori" },
|
||||
"installer-rejected": { firstName: "Paolo", businessName: "Impianti Verdi Srl", reason: "Partita IVA non verificabile" },
|
||||
"showroom-booking-confirmed": { name: "Giulia", requestedAt: "2026-10-10T13:00:00.000Z", people: 2, notes: null },
|
||||
}
|
||||
|
||||
const config = loadConfig()
|
||||
const log = pino({ level: "info" })
|
||||
const to = process.argv[2] ?? "anteprima@example.com"
|
||||
const email = new EmailSender(createEmailTransport(config, log), log)
|
||||
for (const template of EMAIL_TEMPLATES) {
|
||||
await email.send({ to, template, data: samples[template] } as never)
|
||||
}
|
||||
await email.flush()
|
||||
log.info({ provider: config.EMAIL_PROVIDER, outbox: config.EMAIL_OUTBOX_DIR, count: EMAIL_TEMPLATES.length }, "anteprima completata")
|
||||
@@ -0,0 +1,44 @@
|
||||
/**
|
||||
* Errore applicativo con codice stabile, serializzato dall'error handler come
|
||||
* { error: { code, message } } con lo status indicato.
|
||||
* Uso: throw new AppError(404, "QUOTE_NOT_FOUND", "Preventivo non trovato")
|
||||
*/
|
||||
export class AppError extends Error {
|
||||
constructor(
|
||||
readonly statusCode: number,
|
||||
readonly code: string,
|
||||
message: string,
|
||||
/** Dettagli per campo (es. VALIDATION_ERROR calcolato dal codice, non dallo schema). */
|
||||
readonly details?: { path: string; message: string }[],
|
||||
) {
|
||||
super(message)
|
||||
this.name = "AppError"
|
||||
}
|
||||
}
|
||||
|
||||
/** Formato di tutte le risposte di errore dell'API. */
|
||||
export type ErrorBody = {
|
||||
error: {
|
||||
code: string
|
||||
message: string
|
||||
/** Solo per VALIDATION_ERROR: campi non validi. */
|
||||
details?: { path: string; message: string }[]
|
||||
}
|
||||
}
|
||||
|
||||
/** Codice di default per status HTTP, quando l'errore non ne porta uno applicativo. */
|
||||
export const STATUS_CODES: Record<number, string> = {
|
||||
400: "BAD_REQUEST",
|
||||
401: "UNAUTHORIZED",
|
||||
403: "FORBIDDEN",
|
||||
404: "NOT_FOUND",
|
||||
405: "METHOD_NOT_ALLOWED",
|
||||
409: "CONFLICT",
|
||||
413: "PAYLOAD_TOO_LARGE",
|
||||
415: "UNSUPPORTED_MEDIA_TYPE",
|
||||
422: "UNPROCESSABLE_ENTITY",
|
||||
429: "RATE_LIMITED",
|
||||
500: "INTERNAL_ERROR",
|
||||
502: "BAD_GATEWAY",
|
||||
503: "SERVICE_UNAVAILABLE",
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
import jwt from "@fastify/jwt"
|
||||
import type { UserRole } from "@iti/shared"
|
||||
import type { FastifyInstance, FastifyReply, FastifyRequest } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import { AppError } from "../errors.js"
|
||||
|
||||
/** Payload dell'access token: solo id e ruolo (niente dati personali). */
|
||||
export type AccessTokenPayload = { sub: string; role: UserRole }
|
||||
|
||||
declare module "@fastify/jwt" {
|
||||
interface FastifyJWT {
|
||||
payload: AccessTokenPayload
|
||||
user: AccessTokenPayload
|
||||
}
|
||||
}
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** onRequest/preHandler: richiede un access token valido (Authorization: Bearer ...). */
|
||||
authenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>
|
||||
/**
|
||||
* onRequest/preHandler per le route pubbliche che cambiano con l'utente (shop): senza header
|
||||
* Authorization la richiesta è anonima; con un token non valido o scaduto → 401 (il frontend
|
||||
* fa il refresh invece di ricevere silenziosamente i dati da anonimo).
|
||||
*/
|
||||
optionalAuthenticate: (request: FastifyRequest, reply: FastifyReply) => Promise<void>
|
||||
/** onRequest/preHandler: autentica e richiede uno dei ruoli indicati (403 altrimenti). */
|
||||
requireRole: (...roles: UserRole[]) => (request: FastifyRequest, reply: FastifyReply) => Promise<void>
|
||||
/** Firma un access token per l'utente (durata ACCESS_TOKEN_TTL_SECONDS). */
|
||||
signAccessToken: (payload: AccessTokenPayload) => string
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Access token JWT HS256 di breve durata, restituito nel body e tenuto in memoria dal frontend.
|
||||
* Il ruolo nel token vale fino alla scadenza: un cambio di ruolo (es. installatore approvato)
|
||||
* si riflette al refresh successivo, che rilegge l'utente dal DB.
|
||||
*/
|
||||
export default fp(
|
||||
async function authPlugin(app: FastifyInstance) {
|
||||
await app.register(jwt, {
|
||||
secret: app.config.JWT_SECRET,
|
||||
sign: { algorithm: "HS256", expiresIn: app.config.ACCESS_TOKEN_TTL_SECONDS },
|
||||
verify: { algorithms: ["HS256"] },
|
||||
messages: {
|
||||
noAuthorizationInHeaderMessage: "Autenticazione richiesta",
|
||||
authorizationTokenExpiredMessage: "Sessione scaduta",
|
||||
authorizationTokenInvalid: "Token non valido",
|
||||
authorizationTokenUntrusted: "Token non valido",
|
||||
authorizationTokenUnsigned: "Token non valido",
|
||||
badRequestErrorMessage: "Formato del token non valido",
|
||||
badCookieRequestErrorMessage: "Formato del token non valido",
|
||||
},
|
||||
})
|
||||
|
||||
app.decorate("signAccessToken", (payload: AccessTokenPayload) => app.jwt.sign(payload))
|
||||
|
||||
app.decorate("authenticate", async (request: FastifyRequest) => {
|
||||
try {
|
||||
await request.jwtVerify()
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Autenticazione richiesta"
|
||||
throw new AppError(401, "UNAUTHORIZED", message)
|
||||
}
|
||||
})
|
||||
|
||||
app.decorate("optionalAuthenticate", async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (request.headers.authorization) await app.authenticate(request, reply)
|
||||
})
|
||||
|
||||
app.decorate("requireRole", (...roles: UserRole[]) => {
|
||||
return async (request: FastifyRequest, reply: FastifyReply) => {
|
||||
if (!request.user) await app.authenticate(request, reply)
|
||||
if (!roles.includes(request.user.role)) {
|
||||
throw new AppError(403, "FORBIDDEN", "Permessi insufficienti")
|
||||
}
|
||||
}
|
||||
})
|
||||
},
|
||||
{ name: "auth", dependencies: ["config"] },
|
||||
)
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import type { Config } from "../config/env.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** Configurazione validata all'avvio (vedi src/config/env.ts). */
|
||||
config: Config
|
||||
}
|
||||
}
|
||||
|
||||
/** Espone la configurazione validata come `app.config`. */
|
||||
export default fp(
|
||||
async function configPlugin(app: FastifyInstance, opts: { config: Config }) {
|
||||
app.decorate("config", opts.config)
|
||||
},
|
||||
{ name: "config" },
|
||||
)
|
||||
@@ -0,0 +1,25 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import { createDb, type Database } from "../db/client.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** Istanza Drizzle su iti_app (schema in src/db/schema). */
|
||||
db: Database
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Espone Drizzle come `app.db`. postgres.js si connette alla prima query (nessuna connessione
|
||||
* all'avvio); il pool viene chiuso con il server.
|
||||
*/
|
||||
export default fp(
|
||||
async function dbPlugin(app: FastifyInstance) {
|
||||
const { db, close } = createDb(app.config.DATABASE_URL)
|
||||
app.decorate("db", db)
|
||||
app.addHook("onClose", async () => {
|
||||
await close()
|
||||
})
|
||||
},
|
||||
{ name: "db", dependencies: ["config"] },
|
||||
)
|
||||
@@ -0,0 +1,77 @@
|
||||
import type { FastifyError, FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import {
|
||||
hasZodFastifySchemaValidationErrors,
|
||||
isResponseSerializationError,
|
||||
} from "fastify-type-provider-zod"
|
||||
import { AppError, type ErrorBody, STATUS_CODES } from "../errors.js"
|
||||
import { MedusaError } from "../services/medusa-client.js"
|
||||
|
||||
/**
|
||||
* Error handler e not-found handler centralizzati: ogni errore diventa
|
||||
* { error: { code, message } } (+ details per la validazione). Mai stack nella risposta;
|
||||
* in produzione i 5xx hanno un messaggio generico (il dettaglio resta nei log).
|
||||
*/
|
||||
export default fp(
|
||||
async function errorHandler(app: FastifyInstance) {
|
||||
const isProduction = app.config.NODE_ENV === "production"
|
||||
|
||||
app.setErrorHandler((err: FastifyError | AppError, request, reply) => {
|
||||
// Input non valido (schemi zod su body/query/params/headers)
|
||||
if (hasZodFastifySchemaValidationErrors(err)) {
|
||||
const body: ErrorBody = {
|
||||
error: {
|
||||
code: "VALIDATION_ERROR",
|
||||
message: "Dati della richiesta non validi",
|
||||
details: err.validation.map((v) => ({
|
||||
path: [err.validationContext, ...String(v.instancePath).split("/").filter(Boolean)].join("."),
|
||||
message: v.message ?? "non valido",
|
||||
})),
|
||||
},
|
||||
}
|
||||
return reply.status(400).send(body)
|
||||
}
|
||||
|
||||
// Risposta che non rispetta lo schema di output: bug lato server
|
||||
if (isResponseSerializationError(err)) {
|
||||
request.log.error({ err, issues: err.cause.issues }, "risposta non conforme allo schema")
|
||||
return reply.status(500).send({
|
||||
error: { code: "INTERNAL_ERROR", message: "Errore interno" },
|
||||
} satisfies ErrorBody)
|
||||
}
|
||||
|
||||
// Errori di Medusa non gestiti dalla route: 503 se irraggiungibile, 502 altrimenti.
|
||||
// Il dettaglio (URL, messaggio Medusa) va solo nei log.
|
||||
if (err instanceof MedusaError) {
|
||||
request.log.error({ err: { message: err.message, kind: err.kind, status: err.status, type: err.type } }, "errore Medusa")
|
||||
const body: ErrorBody =
|
||||
err.kind === "unavailable"
|
||||
? { error: { code: "SHOP_UNAVAILABLE", message: "Il negozio non è al momento disponibile, riprova tra poco" } }
|
||||
: { error: { code: "BAD_GATEWAY", message: "Errore nella comunicazione con il negozio" } }
|
||||
return reply.status(err.kind === "unavailable" ? 503 : 502).send(body)
|
||||
}
|
||||
|
||||
const status = err instanceof AppError ? err.statusCode : (err.statusCode ?? 500)
|
||||
const isServerError = status >= 500
|
||||
|
||||
if (isServerError) request.log.error({ err }, "errore non gestito")
|
||||
else request.log.info({ err: { message: err.message, code: err.code } }, "richiesta rifiutata")
|
||||
|
||||
const code =
|
||||
err instanceof AppError
|
||||
? err.code
|
||||
: (STATUS_CODES[status] ?? (isServerError ? "INTERNAL_ERROR" : "BAD_REQUEST"))
|
||||
const message = isServerError && isProduction ? "Errore interno" : err.message
|
||||
|
||||
const details = err instanceof AppError && err.details?.length ? { details: err.details } : {}
|
||||
return reply.status(status).send({ error: { code, message, ...details } } satisfies ErrorBody)
|
||||
})
|
||||
|
||||
app.setNotFoundHandler((request, reply) => {
|
||||
reply.status(404).send({
|
||||
error: { code: "NOT_FOUND", message: `Route ${request.method} ${request.url} non trovata` },
|
||||
} satisfies ErrorBody)
|
||||
})
|
||||
},
|
||||
{ name: "error-handler", dependencies: ["config"] },
|
||||
)
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import { type FetchFn, MedusaClient } from "../services/medusa-client.js"
|
||||
import { trySync } from "../services/medusa-sync.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** Client Medusa: `medusa.store` (Store API) e `medusa.internal` (route /internal/*). */
|
||||
medusa: MedusaClient
|
||||
/**
|
||||
* Avvia una sincronizzazione con Medusa senza attenderla (la risposta HTTP non dipende da Medusa).
|
||||
* Gli errori diventano warning nei log; le operazioni in corso vengono attese alla chiusura del server.
|
||||
*/
|
||||
syncInBackground: (what: string, userId: string, fn: () => Promise<unknown>) => void
|
||||
/** Attende le sincronizzazioni in background in corso (usato da onClose e dai test). */
|
||||
flushSyncs: () => Promise<void>
|
||||
}
|
||||
}
|
||||
|
||||
/** Espone il client Medusa come `app.medusa`. `fetch` è sostituibile nei test (Medusa mockato). */
|
||||
export default fp(
|
||||
async function medusaClientPlugin(app: FastifyInstance, opts: { fetch?: FetchFn | undefined }) {
|
||||
app.decorate(
|
||||
"medusa",
|
||||
new MedusaClient({
|
||||
baseUrl: app.config.MEDUSA_URL,
|
||||
publishableKey: app.config.MEDUSA_PUBLISHABLE_KEY,
|
||||
internalSecret: app.config.INTERNAL_API_SECRET,
|
||||
timeoutMs: app.config.MEDUSA_TIMEOUT_MS,
|
||||
...(opts.fetch ? { fetch: opts.fetch } : {}),
|
||||
}),
|
||||
)
|
||||
|
||||
const pending = new Set<Promise<unknown>>()
|
||||
app.decorate("syncInBackground", (what: string, userId: string, fn: () => Promise<unknown>) => {
|
||||
const p = trySync(app.log, what, userId, fn).finally(() => pending.delete(p))
|
||||
pending.add(p)
|
||||
})
|
||||
app.decorate("flushSyncs", async () => {
|
||||
while (pending.size > 0) await Promise.allSettled([...pending])
|
||||
})
|
||||
app.addHook("onClose", async () => {
|
||||
await app.flushSyncs()
|
||||
})
|
||||
},
|
||||
{ name: "medusa-client", dependencies: ["config"] },
|
||||
)
|
||||
@@ -0,0 +1,44 @@
|
||||
import cookie from "@fastify/cookie"
|
||||
import cors from "@fastify/cors"
|
||||
import helmet from "@fastify/helmet"
|
||||
import rateLimit from "@fastify/rate-limit"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
|
||||
/**
|
||||
* Plugin di sicurezza comuni a tutta l'API.
|
||||
* - helmet: header di sicurezza; CORP "same-site" perché public/private girano su sottodomini dello stesso dominio.
|
||||
* - cors: solo le origini in CORS_ORIGINS, con credenziali (cookie di sessione/refresh).
|
||||
* - cookie: cookie firmati con COOKIE_SECRET.
|
||||
* - rate-limit globale per IP (RATE_LIMIT_MAX ogni RATE_LIMIT_WINDOW); le route possono
|
||||
* abbassarlo (es. login) o disattivarlo con `config: { rateLimit: false }`.
|
||||
*/
|
||||
export default fp(
|
||||
async function security(app: FastifyInstance) {
|
||||
const { config } = app
|
||||
|
||||
await app.register(helmet, {
|
||||
crossOriginResourcePolicy: { policy: "same-site" },
|
||||
})
|
||||
|
||||
await app.register(cors, {
|
||||
origin: config.CORS_ORIGINS,
|
||||
credentials: true,
|
||||
methods: ["GET", "HEAD", "POST", "PUT", "PATCH", "DELETE"],
|
||||
})
|
||||
|
||||
await app.register(cookie, { secret: config.COOKIE_SECRET })
|
||||
|
||||
await app.register(rateLimit, {
|
||||
global: true,
|
||||
max: config.RATE_LIMIT_MAX,
|
||||
timeWindow: config.RATE_LIMIT_WINDOW,
|
||||
// Errore gestito dall'error handler centralizzato (status 429 -> RATE_LIMITED)
|
||||
errorResponseBuilder: (_req, ctx) =>
|
||||
Object.assign(new Error(`Troppe richieste, riprova tra ${ctx.after}`), {
|
||||
statusCode: ctx.statusCode,
|
||||
}),
|
||||
})
|
||||
},
|
||||
{ name: "security", dependencies: ["config"] },
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import { ShopService } from "../services/shop.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** Operazioni e-commerce verso Medusa (catalogo con cache, carrello, checkout). */
|
||||
shop: ShopService
|
||||
}
|
||||
}
|
||||
|
||||
export default fp(
|
||||
async function shopPlugin(app: FastifyInstance) {
|
||||
app.decorate("shop", new ShopService(app, app.config.MEDUSA_PAYMENT_PROVIDER_ID))
|
||||
},
|
||||
{ name: "shop", dependencies: ["config", "db", "medusa-client"] },
|
||||
)
|
||||
@@ -0,0 +1,42 @@
|
||||
import multipart from "@fastify/multipart"
|
||||
import fastifyStatic from "@fastify/static"
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import fp from "fastify-plugin"
|
||||
import { LocalDiskStorage, type StorageService } from "../services/storage.js"
|
||||
|
||||
declare module "fastify" {
|
||||
interface FastifyInstance {
|
||||
/** Storage dei file (disco locale; interfaccia sostituibile con S3/MinIO). */
|
||||
storage: StorageService
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* - `app.storage`: disco locale sotto UPLOAD_DIR (aree public/ e private/).
|
||||
* - multipart: un solo file per richiesta, al massimo UPLOAD_MAX_BYTES; campi piccoli.
|
||||
* - Fuori dalla produzione l'API serve l'area public/ su /uploads (in produzione lo fa Caddy).
|
||||
* La radice statica è UPLOAD_DIR/public: l'area private/ non è mai raggiungibile via URL.
|
||||
*/
|
||||
export default fp(
|
||||
async function storagePlugin(app: FastifyInstance, opts: { storage?: StorageService | undefined }) {
|
||||
const { config } = app
|
||||
const local = new LocalDiskStorage(config.UPLOAD_DIR, config.PUBLIC_UPLOADS_URL)
|
||||
app.decorate("storage", opts.storage ?? local)
|
||||
|
||||
await app.register(multipart, {
|
||||
limits: { fileSize: config.UPLOAD_MAX_BYTES, files: 1, fields: 10, fieldSize: 10_000, parts: 20 },
|
||||
})
|
||||
|
||||
if (config.NODE_ENV !== "production") {
|
||||
await app.register(fastifyStatic, {
|
||||
root: local.areaRoot("public"),
|
||||
prefix: "/uploads/",
|
||||
decorateReply: false,
|
||||
// Niente dotfile, niente indici di directory
|
||||
dotfiles: "deny",
|
||||
index: false,
|
||||
})
|
||||
}
|
||||
},
|
||||
{ name: "storage", dependencies: ["config"] },
|
||||
)
|
||||
@@ -0,0 +1,272 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { blogCategories, blogPosts, type NewBlogPost } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { isUniqueViolation, renderMarkdown, SLUG_RE, slugify, toAdminDto, toCategoryDto, uniqueSlug } from "../../services/blog.js"
|
||||
import { categoryDto, postDetailDto, tsQuery } from "../public/blog.js"
|
||||
|
||||
const adminPostDto = postDetailDto.extend({ status: z.enum(["draft", "published"]) })
|
||||
|
||||
const nullableText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => v || null)
|
||||
.nullable()
|
||||
|
||||
const slug = z.string().trim().toLowerCase().max(80).regex(SLUG_RE, "Slug: solo lettere minuscole, numeri e trattini")
|
||||
|
||||
/**
|
||||
* Campi di un post SENZA default: in zod 4 i .default() valgono anche dentro .partial(), quindi un
|
||||
* PATCH costruito da uno schema con default sovrascriverebbe i campi assenti (es. status → draft).
|
||||
*/
|
||||
const postFields = z.object({
|
||||
title: z.string().trim().min(1, "Titolo obbligatorio").max(200),
|
||||
/** Se assente viene generato dal titolo; univoco (suffisso -2, -3, ...). */
|
||||
slug: slug.optional(),
|
||||
excerpt: nullableText(500).optional(),
|
||||
contentMd: z.string().max(200_000),
|
||||
/** Percorso restituito da POST /admin/uploads/image (area public). */
|
||||
coverPath: z
|
||||
.string()
|
||||
.regex(/^images\/[a-z0-9/._-]+$/, "Percorso copertina non valido (usa il path di /admin/uploads/image)")
|
||||
.nullable()
|
||||
.optional(),
|
||||
status: z.enum(["draft", "published"]),
|
||||
/** Data di pubblicazione (ISO). Se si pubblica senza data → adesso; una data futura programma il post. */
|
||||
publishedAt: z.iso.datetime({ offset: true }).nullable().optional(),
|
||||
seoTitle: nullableText(70).optional(),
|
||||
seoDescription: nullableText(160).optional(),
|
||||
categoryId: z.uuid().nullable().optional(),
|
||||
})
|
||||
|
||||
/** Creazione: default per contenuto e stato. */
|
||||
const postCreate = postFields.extend({
|
||||
contentMd: postFields.shape.contentMd.default(""),
|
||||
status: postFields.shape.status.default("draft"),
|
||||
})
|
||||
|
||||
const categoryInput = z.object({
|
||||
name: z.string().trim().min(1).max(100),
|
||||
slug: slug.optional(),
|
||||
description: nullableText(500).optional(),
|
||||
})
|
||||
|
||||
const idParam = z.object({ id: z.uuid() })
|
||||
|
||||
/** Blog: post e categorie (solo admin, hook in routes/admin/index.ts). */
|
||||
const adminBlogRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db } = app
|
||||
const coverUrl = (path: string) => app.storage.publicUrl(path)
|
||||
|
||||
const loadPost = async (id: string) => {
|
||||
const [row] = await db
|
||||
.select({ post: blogPosts, category: blogCategories })
|
||||
.from(blogPosts)
|
||||
.leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id))
|
||||
.where(eq(blogPosts.id, id))
|
||||
if (!row) throw new AppError(404, "POST_NOT_FOUND", "Articolo non trovato")
|
||||
return toAdminDto(row.post, row.category, coverUrl)
|
||||
}
|
||||
|
||||
const assertCategory = async (categoryId: string | null | undefined) => {
|
||||
if (!categoryId) return
|
||||
const [c] = await db.select({ id: blogCategories.id }).from(blogCategories).where(eq(blogCategories.id, categoryId))
|
||||
if (!c) throw new AppError(400, "CATEGORY_NOT_FOUND", "Categoria inesistente")
|
||||
}
|
||||
|
||||
/** publishedAt effettivo: alla pubblicazione senza data → ora; data esplicita rispettata. */
|
||||
const publishedAtFor = (status: "draft" | "published", requested: string | null | undefined, current: Date | null) => {
|
||||
if (requested !== undefined) return requested ? new Date(requested) : status === "published" ? new Date() : null
|
||||
if (status === "published") return current ?? new Date()
|
||||
return current
|
||||
}
|
||||
|
||||
/** Esegue un insert/update ritentando con un nuovo slug se un altro lo occupa nel frattempo. */
|
||||
const withSlugRetry = async <T>(run: () => Promise<T>, explicit: boolean): Promise<T> => {
|
||||
for (let attempt = 0; ; attempt++) {
|
||||
try {
|
||||
return await run()
|
||||
} catch (err) {
|
||||
if (!isUniqueViolation(err)) throw err
|
||||
if (explicit || attempt >= 3) throw new AppError(409, "SLUG_TAKEN", "Slug già in uso")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Post ---
|
||||
|
||||
app.get(
|
||||
"/blog/posts",
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
status: z.enum(["draft", "published"]).optional(),
|
||||
q: z.string().trim().min(1).max(200).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
items: z.array(adminPostDto),
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, q, limit, offset } = request.query
|
||||
const where = and(
|
||||
status ? eq(blogPosts.status, status) : undefined,
|
||||
q ? sql`${blogPosts.searchVector} @@ ${tsQuery(q)}` : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db
|
||||
.select({ post: blogPosts, category: blogCategories })
|
||||
.from(blogPosts)
|
||||
.leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id))
|
||||
.where(where)
|
||||
.orderBy(desc(blogPosts.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(blogPosts).where(where),
|
||||
])
|
||||
return { items: rows.map((r) => toAdminDto(r.post, r.category, coverUrl)), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/blog/posts/:id", { schema: { params: idParam, response: { 200: adminPostDto } } }, async (request) =>
|
||||
loadPost(request.params.id),
|
||||
)
|
||||
|
||||
app.post("/blog/posts", { schema: { body: postCreate, response: { 201: adminPostDto } } }, async (request, reply) => {
|
||||
const b = request.body
|
||||
await assertCategory(b.categoryId)
|
||||
const base = b.slug ?? slugify(b.title)
|
||||
const id = await withSlugRetry(async () => {
|
||||
const values: NewBlogPost = {
|
||||
slug: b.slug ?? (await uniqueSlug(db, blogPosts, base)),
|
||||
title: b.title,
|
||||
excerpt: b.excerpt ?? null,
|
||||
contentMd: b.contentMd,
|
||||
coverPath: b.coverPath ?? null,
|
||||
status: b.status,
|
||||
publishedAt: publishedAtFor(b.status, b.publishedAt, null),
|
||||
seoTitle: b.seoTitle ?? null,
|
||||
seoDescription: b.seoDescription ?? null,
|
||||
categoryId: b.categoryId ?? null,
|
||||
authorId: request.user.sub,
|
||||
}
|
||||
const [row] = await db.insert(blogPosts).values(values).returning({ id: blogPosts.id })
|
||||
return row!.id
|
||||
}, b.slug !== undefined)
|
||||
return reply.status(201).send(await loadPost(id))
|
||||
})
|
||||
|
||||
// PATCH /admin/blog/posts/:id — campi parziali; lo slug cambia solo se indicato (URL stabili)
|
||||
app.patch(
|
||||
"/blog/posts/:id",
|
||||
{ schema: { params: idParam, body: postFields.partial(), response: { 200: adminPostDto } } },
|
||||
async (request) => {
|
||||
const b = request.body
|
||||
const [current] = await db.select().from(blogPosts).where(eq(blogPosts.id, request.params.id))
|
||||
if (!current) throw new AppError(404, "POST_NOT_FOUND", "Articolo non trovato")
|
||||
await assertCategory(b.categoryId)
|
||||
const status = b.status ?? current.status
|
||||
|
||||
await withSlugRetry(async () => {
|
||||
await db
|
||||
.update(blogPosts)
|
||||
.set({
|
||||
...(b.title !== undefined ? { title: b.title } : {}),
|
||||
...(b.slug !== undefined ? { slug: b.slug } : {}),
|
||||
...(b.excerpt !== undefined ? { excerpt: b.excerpt } : {}),
|
||||
...(b.contentMd !== undefined ? { contentMd: b.contentMd } : {}),
|
||||
...(b.coverPath !== undefined ? { coverPath: b.coverPath } : {}),
|
||||
...(b.seoTitle !== undefined ? { seoTitle: b.seoTitle } : {}),
|
||||
...(b.seoDescription !== undefined ? { seoDescription: b.seoDescription } : {}),
|
||||
...(b.categoryId !== undefined ? { categoryId: b.categoryId } : {}),
|
||||
status,
|
||||
publishedAt: publishedAtFor(status, b.publishedAt, current.publishedAt),
|
||||
})
|
||||
.where(eq(blogPosts.id, current.id))
|
||||
}, true)
|
||||
return loadPost(current.id)
|
||||
},
|
||||
)
|
||||
|
||||
app.delete("/blog/posts/:id", { schema: { params: idParam } }, async (request, reply) => {
|
||||
const [row] = await db.delete(blogPosts).where(eq(blogPosts.id, request.params.id)).returning({ id: blogPosts.id })
|
||||
if (!row) throw new AppError(404, "POST_NOT_FOUND", "Articolo non trovato")
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// POST /admin/blog/preview — anteprima live dell'editor: markdown → HTML sanitizzato
|
||||
app.post(
|
||||
"/blog/preview",
|
||||
{
|
||||
schema: {
|
||||
body: z.object({ markdown: z.string().max(200_000) }),
|
||||
response: { 200: z.object({ html: z.string() }) },
|
||||
},
|
||||
},
|
||||
async (request) => ({ html: renderMarkdown(request.body.markdown) }),
|
||||
)
|
||||
|
||||
// --- Categorie ---
|
||||
|
||||
app.get("/blog/categories", { schema: { response: { 200: z.array(categoryDto) } } }, async () =>
|
||||
(await db.select().from(blogCategories).orderBy(blogCategories.name)).map(toCategoryDto),
|
||||
)
|
||||
|
||||
app.post("/blog/categories", { schema: { body: categoryInput, response: { 201: categoryDto } } }, async (request, reply) => {
|
||||
const b = request.body
|
||||
const row = await withSlugRetry(async () => {
|
||||
const [c] = await db
|
||||
.insert(blogCategories)
|
||||
.values({
|
||||
name: b.name,
|
||||
slug: b.slug ?? (await uniqueSlug(db, blogCategories, slugify(b.name))),
|
||||
description: b.description ?? null,
|
||||
})
|
||||
.returning()
|
||||
return c!
|
||||
}, b.slug !== undefined)
|
||||
return reply.status(201).send(toCategoryDto(row))
|
||||
})
|
||||
|
||||
app.patch(
|
||||
"/blog/categories/:id",
|
||||
{ schema: { params: idParam, body: categoryInput.partial(), response: { 200: categoryDto } } },
|
||||
async (request) => {
|
||||
const b = request.body
|
||||
const row = await withSlugRetry(async () => {
|
||||
const [c] = await db
|
||||
.update(blogCategories)
|
||||
.set({
|
||||
...(b.name !== undefined ? { name: b.name } : {}),
|
||||
...(b.slug !== undefined ? { slug: b.slug } : {}),
|
||||
...(b.description !== undefined ? { description: b.description } : {}),
|
||||
})
|
||||
.where(eq(blogCategories.id, request.params.id))
|
||||
.returning()
|
||||
return c
|
||||
}, true)
|
||||
if (!row) throw new AppError(404, "CATEGORY_NOT_FOUND", "Categoria non trovata")
|
||||
return toCategoryDto(row)
|
||||
},
|
||||
)
|
||||
|
||||
// DELETE — i post della categoria restano, senza categoria (FK on delete set null)
|
||||
app.delete("/blog/categories/:id", { schema: { params: idParam } }, async (request, reply) => {
|
||||
const [row] = await db.delete(blogCategories).where(eq(blogCategories.id, request.params.id)).returning({ id: blogCategories.id })
|
||||
if (!row) throw new AppError(404, "CATEGORY_NOT_FOUND", "Categoria non trovata")
|
||||
return reply.status(204).send()
|
||||
})
|
||||
}
|
||||
|
||||
export default adminBlogRoutes
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ContactRequestDto } from "@iti/shared"
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { type ContactRequest, contactRequests } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { idParam, pageQuery, paginated } from "../private/schemas.js"
|
||||
|
||||
const contactStatus = z.enum(["new", "in_progress", "closed"])
|
||||
|
||||
const contactRequestDto = z.object({
|
||||
id: z.uuid(),
|
||||
userId: z.uuid().nullable(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
subject: z.string().nullable(),
|
||||
message: z.string(),
|
||||
status: contactStatus,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<ContactRequestDto>
|
||||
|
||||
const toDto = (c: ContactRequest): ContactRequestDto => ({
|
||||
id: c.id,
|
||||
userId: c.userId,
|
||||
name: c.name,
|
||||
email: c.email,
|
||||
phone: c.phone,
|
||||
subject: c.subject,
|
||||
message: c.message,
|
||||
status: c.status,
|
||||
createdAt: c.createdAt.toISOString(),
|
||||
updatedAt: c.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
/** Richieste dal form contatti (solo admin). */
|
||||
const adminContactRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db } = app
|
||||
|
||||
app.get(
|
||||
"/contact-requests",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({ status: contactStatus.optional(), q: z.string().trim().min(1).max(100).optional() }),
|
||||
response: { 200: paginated(contactRequestDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, q, limit, offset } = request.query
|
||||
const pattern = q ? `%${q.replace(/[\\%_]/g, "\\$&")}%` : undefined
|
||||
const where = and(
|
||||
status ? eq(contactRequests.status, status) : undefined,
|
||||
pattern ? or(ilike(contactRequests.email, pattern), ilike(contactRequests.name, pattern), ilike(contactRequests.subject, pattern)) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db.select().from(contactRequests).where(where).orderBy(desc(contactRequests.createdAt)).limit(limit).offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(contactRequests).where(where),
|
||||
])
|
||||
return { items: rows.map(toDto), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/contact-requests/:id", { schema: { params: idParam, response: { 200: contactRequestDto } } }, async (request) => {
|
||||
const [row] = await db.select().from(contactRequests).where(eq(contactRequests.id, request.params.id))
|
||||
if (!row) throw new AppError(404, "CONTACT_REQUEST_NOT_FOUND", "Richiesta non trovata")
|
||||
return toDto(row)
|
||||
})
|
||||
|
||||
app.patch(
|
||||
"/contact-requests/:id",
|
||||
{ schema: { params: idParam, body: z.object({ status: contactStatus }), response: { 200: contactRequestDto } } },
|
||||
async (request) => {
|
||||
const [row] = await db
|
||||
.update(contactRequests)
|
||||
.set({ status: request.body.status })
|
||||
.where(eq(contactRequests.id, request.params.id))
|
||||
.returning()
|
||||
if (!row) throw new AppError(404, "CONTACT_REQUEST_NOT_FOUND", "Richiesta non trovata")
|
||||
return toDto(row)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default adminContactRoutes
|
||||
@@ -0,0 +1,161 @@
|
||||
import { and, desc, eq, ilike, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { documents } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { receiveUpload, toDocumentDto, UPLOAD_RATE_LIMIT } from "../../services/documents.js"
|
||||
import { DOCUMENT_KINDS, IMAGE_KINDS } from "../../services/storage.js"
|
||||
import { documentDto, paginatedDocuments } from "../public/documents.js"
|
||||
|
||||
const visibility = z.enum(["public", "customer", "installer", "admin"])
|
||||
const optionalText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => v || null)
|
||||
.nullable()
|
||||
.optional()
|
||||
|
||||
/**
|
||||
* Metadati di un documento SENZA default (in zod 4 i .default() valgono anche dentro .partial():
|
||||
* il PATCH riporterebbe a "admin" la visibilità quando il campo manca).
|
||||
*/
|
||||
const metadataFields = z.object({
|
||||
title: z.string().trim().min(1, "Titolo obbligatorio").max(200),
|
||||
category: optionalText(100),
|
||||
visibility,
|
||||
/** Handle del prodotto Medusa a cui si riferisce (es. scheda tecnica). */
|
||||
productRef: optionalText(200).pipe(
|
||||
z.string().regex(/^[a-z0-9-]+$/, "Handle prodotto non valido").nullable().optional(),
|
||||
),
|
||||
})
|
||||
|
||||
/** Upload: visibilità di default "admin" (il documento resta riservato finché non lo si cambia). */
|
||||
const metadata = metadataFields.extend({ visibility: visibility.default("admin") })
|
||||
|
||||
/** Gestione documenti e upload (solo admin: hook in routes/admin/index.ts). */
|
||||
const adminDocumentRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, storage } = app
|
||||
|
||||
// POST /admin/documents — multipart: file + title, category?, visibility, productRef?
|
||||
app.post("/documents", { config: { rateLimit: UPLOAD_RATE_LIMIT }, schema: { response: { 201: documentDto } } }, async (request, reply) => {
|
||||
const upload = await receiveUpload(request, storage, {
|
||||
area: "private",
|
||||
prefix: "documents",
|
||||
allowed: DOCUMENT_KINDS,
|
||||
maxBytes: config.UPLOAD_MAX_BYTES,
|
||||
})
|
||||
const parsed = metadata.safeParse(upload.fields)
|
||||
if (!parsed.success) {
|
||||
await storage.delete(upload.area, upload.key)
|
||||
throw new AppError(400, "VALIDATION_ERROR", parsed.error.issues.map((i) => `${i.path.join(".")}: ${i.message}`).join("; "))
|
||||
}
|
||||
try {
|
||||
const [doc] = await app.db
|
||||
.insert(documents)
|
||||
.values({
|
||||
title: parsed.data.title,
|
||||
category: parsed.data.category ?? null,
|
||||
visibility: parsed.data.visibility,
|
||||
productRef: parsed.data.productRef ?? null,
|
||||
filePath: upload.key,
|
||||
mimeType: upload.mime,
|
||||
sizeBytes: upload.size,
|
||||
})
|
||||
.returning()
|
||||
request.log.info({ documentId: doc!.id, visibility: doc!.visibility, size: upload.size }, "documento caricato")
|
||||
return reply.status(201).send(toDocumentDto(doc!))
|
||||
} catch (err) {
|
||||
await storage.delete(upload.area, upload.key)
|
||||
throw err
|
||||
}
|
||||
})
|
||||
|
||||
// GET /admin/documents — tutti i documenti, con filtri
|
||||
app.get(
|
||||
"/documents",
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
visibility: visibility.optional(),
|
||||
category: z.string().trim().min(1).max(100).optional(),
|
||||
q: z.string().trim().min(1).max(100).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
}),
|
||||
response: { 200: paginatedDocuments },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { visibility: v, category, q, limit, offset } = request.query
|
||||
const where = and(
|
||||
v ? eq(documents.visibility, v) : undefined,
|
||||
category ? eq(documents.category, category) : undefined,
|
||||
q ? ilike(documents.title, `%${q.replace(/[\\%_]/g, "\\$&")}%`) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
app.db.select().from(documents).where(where).orderBy(desc(documents.createdAt)).limit(limit).offset(offset),
|
||||
app.db.select({ n: sql<number>`count(*)::int` }).from(documents).where(where),
|
||||
])
|
||||
return { items: rows.map(toDocumentDto), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
// PATCH /admin/documents/:id — solo metadati (per sostituire il file: nuovo upload + DELETE)
|
||||
app.patch(
|
||||
"/documents/:id",
|
||||
{
|
||||
schema: {
|
||||
params: z.object({ id: z.uuid() }),
|
||||
body: metadataFields.partial().refine((b) => Object.keys(b).length > 0, "Nessun campo da aggiornare"),
|
||||
response: { 200: documentDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const b = request.body
|
||||
const [doc] = await app.db
|
||||
.update(documents)
|
||||
.set({
|
||||
...(b.title !== undefined ? { title: b.title } : {}),
|
||||
...(b.category !== undefined ? { category: b.category } : {}),
|
||||
...(b.visibility !== undefined ? { visibility: b.visibility } : {}),
|
||||
...(b.productRef !== undefined ? { productRef: b.productRef } : {}),
|
||||
})
|
||||
.where(eq(documents.id, request.params.id))
|
||||
.returning()
|
||||
if (!doc) throw new AppError(404, "DOCUMENT_NOT_FOUND", "Documento non trovato")
|
||||
return toDocumentDto(doc)
|
||||
},
|
||||
)
|
||||
|
||||
// DELETE /admin/documents/:id — riga e file (i preventivi che lo usavano come PDF restano, senza file)
|
||||
app.delete("/documents/:id", { schema: { params: z.object({ id: z.uuid() }) } }, async (request, reply) => {
|
||||
const [doc] = await app.db.delete(documents).where(eq(documents.id, request.params.id)).returning()
|
||||
if (!doc) throw new AppError(404, "DOCUMENT_NOT_FOUND", "Documento non trovato")
|
||||
try {
|
||||
await storage.delete("private", doc.filePath)
|
||||
} catch (err) {
|
||||
request.log.error({ err, documentId: doc.id }, "file del documento non cancellato dallo storage")
|
||||
}
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// POST /admin/uploads/image — immagine nell'area public (blog, foto): restituisce l'URL pubblico
|
||||
app.post(
|
||||
"/uploads/image",
|
||||
{ config: { rateLimit: UPLOAD_RATE_LIMIT }, schema: { response: { 201: z.object({ url: z.string(), path: z.string(), mimeType: z.string(), sizeBytes: z.number() }) } } },
|
||||
async (request, reply) => {
|
||||
const upload = await receiveUpload(request, storage, {
|
||||
area: "public",
|
||||
prefix: "images",
|
||||
allowed: IMAGE_KINDS,
|
||||
maxBytes: config.UPLOAD_MAX_BYTES,
|
||||
})
|
||||
request.log.info({ path: upload.key, size: upload.size }, "immagine pubblica caricata")
|
||||
return reply.status(201).send({ url: storage.publicUrl(upload.key), path: upload.key, mimeType: upload.mime, sizeBytes: upload.size })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default adminDocumentRoutes
|
||||
@@ -0,0 +1,36 @@
|
||||
import type { FastifyPluginAsync } from "fastify"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
import adminBlogRoutes from "./blog.js"
|
||||
import adminContactRoutes from "./contacts.js"
|
||||
import adminDocumentRoutes from "./documents.js"
|
||||
import adminInstallerRoutes from "./installers.js"
|
||||
import adminQuoteRoutes from "./quotes.js"
|
||||
import adminShowroomRoutes from "./showroom.js"
|
||||
import adminSummaryRoutes from "./summary.js"
|
||||
import adminTicketRoutes from "./tickets.js"
|
||||
import adminUserRoutes from "./users.js"
|
||||
|
||||
/**
|
||||
* Area admin (prefisso /admin): un unico hook richiede il ruolo admin per tutte le route registrate qui.
|
||||
* Prima il ruolo nel token (nessuna query per chi non è admin), poi l'utente riletto dal DB: un admin
|
||||
* disabilitato o declassato perde l'accesso subito, senza attendere la scadenza del token.
|
||||
*/
|
||||
const adminRoutes: FastifyPluginAsync = async (app) => {
|
||||
app.addHook("onRequest", app.requireRole("admin"))
|
||||
app.addHook("onRequest", async (request) => {
|
||||
const user = await requireActiveUser(app.db, request)
|
||||
if (user.role !== "admin") throw new AppError(403, "FORBIDDEN", "Permessi insufficienti")
|
||||
})
|
||||
await app.register(adminDocumentRoutes)
|
||||
await app.register(adminBlogRoutes)
|
||||
await app.register(adminQuoteRoutes)
|
||||
await app.register(adminTicketRoutes)
|
||||
await app.register(adminInstallerRoutes)
|
||||
await app.register(adminContactRoutes)
|
||||
await app.register(adminShowroomRoutes)
|
||||
await app.register(adminUserRoutes)
|
||||
await app.register(adminSummaryRoutes)
|
||||
}
|
||||
|
||||
export default adminRoutes
|
||||
@@ -0,0 +1,212 @@
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { installerProfiles, quoteRequests, users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { PROVINCE_CENTROIDS } from "../../services/geo.js"
|
||||
import { findNearbyInstallers, toInstallerAdminDto } from "../../services/installers.js"
|
||||
import { syncInstallerGroup, trySync } from "../../services/medusa-sync.js"
|
||||
import { idParam, pageQuery, paginated } from "../private/schemas.js"
|
||||
import { geoPoint, installerProfileDto } from "../private/installer.js"
|
||||
|
||||
const installerAdminDto = installerProfileDto.extend({
|
||||
email: z.string(),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
notes: z.string().nullable(),
|
||||
disabled: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
const nearbyDto = z.object({
|
||||
userId: z.uuid(),
|
||||
businessName: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
location: z.object({ lat: z.number(), lng: z.number() }),
|
||||
serviceRadiusKm: z.number().int(),
|
||||
certifications: z.array(z.string()),
|
||||
distanceKm: z.number(),
|
||||
})
|
||||
|
||||
/** Esito di approvazione/rifiuto: profilo + allineamento del customer group Medusa. */
|
||||
const decisionDto = installerAdminDto.extend({ medusaSynced: z.boolean() })
|
||||
|
||||
/** Installatori: elenco, approvazione/rifiuto, ricerca per zona (solo admin). */
|
||||
const adminInstallerRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db, config } = app
|
||||
|
||||
const load = async (userId: string) => {
|
||||
const [row] = await db
|
||||
.select({ p: installerProfiles, u: users })
|
||||
.from(installerProfiles)
|
||||
.innerJoin(users, eq(users.id, installerProfiles.userId))
|
||||
.where(eq(installerProfiles.userId, userId))
|
||||
if (!row) throw new AppError(404, "INSTALLER_NOT_FOUND", "Installatore non trovato")
|
||||
return row
|
||||
}
|
||||
|
||||
app.get(
|
||||
"/installers",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({
|
||||
status: z.enum(["pending", "approved", "rejected"]).optional(),
|
||||
q: z.string().trim().min(1).max(100).optional(),
|
||||
}),
|
||||
response: { 200: paginated(installerAdminDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, q, limit, offset } = request.query
|
||||
const pattern = q ? `%${q.replace(/[\\%_]/g, "\\$&")}%` : undefined
|
||||
const where = and(
|
||||
status ? eq(installerProfiles.status, status) : undefined,
|
||||
pattern ? or(ilike(installerProfiles.businessName, pattern), ilike(users.email, pattern)) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db
|
||||
.select({ p: installerProfiles, u: users })
|
||||
.from(installerProfiles)
|
||||
.innerJoin(users, eq(users.id, installerProfiles.userId))
|
||||
.where(where)
|
||||
.orderBy(desc(installerProfiles.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(installerProfiles)
|
||||
.innerJoin(users, eq(users.id, installerProfiles.userId))
|
||||
.where(where),
|
||||
])
|
||||
return { items: rows.map((r) => toInstallerAdminDto(r.p, r.u)), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
// GET /admin/installers/nearby?lat=&lng= — approvati il cui raggio copre il punto, per distanza
|
||||
app.get(
|
||||
"/installers/nearby",
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
lat: z.coerce.number(),
|
||||
lng: z.coerce.number(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
}),
|
||||
response: { 200: z.array(nearbyDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { lat, lng, limit } = request.query
|
||||
const point = geoPoint.safeParse({ lat, lng })
|
||||
if (!point.success) throw new AppError(400, "VALIDATION_ERROR", "Coordinate non valide o fuori dall'Italia")
|
||||
return findNearbyInstallers(db, point.data, limit)
|
||||
},
|
||||
)
|
||||
|
||||
// GET /admin/quote-requests/:id/installers — installatori vicini al capoluogo della provincia della richiesta
|
||||
app.get(
|
||||
"/quote-requests/:id/installers",
|
||||
{
|
||||
schema: {
|
||||
params: idParam,
|
||||
querystring: z.object({ limit: z.coerce.number().int().min(1).max(100).default(20) }),
|
||||
response: { 200: z.object({ province: z.string(), point: z.object({ lat: z.number(), lng: z.number() }), installers: z.array(nearbyDto) }) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const [r] = await db.select({ province: quoteRequests.province }).from(quoteRequests).where(eq(quoteRequests.id, request.params.id))
|
||||
if (!r) throw new AppError(404, "QUOTE_REQUEST_NOT_FOUND", "Richiesta non trovata")
|
||||
const point = r.province ? PROVINCE_CENTROIDS[r.province] : undefined
|
||||
if (!r.province || !point) throw new AppError(409, "PROVINCE_MISSING", "La richiesta non indica una provincia valida")
|
||||
return { province: r.province, point, installers: await findNearbyInstallers(db, point, request.query.limit) }
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/installers/:id", { schema: { params: idParam, response: { 200: installerAdminDto } } }, async (request) => {
|
||||
const { p, u } = await load(request.params.id)
|
||||
return toInstallerAdminDto(p, u)
|
||||
})
|
||||
|
||||
// PATCH /admin/installers/:id — note interne
|
||||
app.patch(
|
||||
"/installers/:id",
|
||||
{
|
||||
schema: {
|
||||
params: idParam,
|
||||
body: z.object({ notes: z.string().trim().max(5000).transform((v) => v || null).nullable() }),
|
||||
response: { 200: installerAdminDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
await load(request.params.id)
|
||||
await db.update(installerProfiles).set({ notes: request.body.notes }).where(eq(installerProfiles.userId, request.params.id))
|
||||
const { p, u } = await load(request.params.id)
|
||||
return toInstallerAdminDto(p, u)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* Approva/rifiuta: stato (e ruolo installer) in transazione, poi allineamento del customer group
|
||||
* Medusa (listino riservato) con syncInstallerGroup, poi email. Se Medusa non risponde la decisione
|
||||
* resta valida (`medusaSynced: false`): la sincronizzazione si ritenta al login dell'installatore o
|
||||
* con POST /admin/installers/:id/sync.
|
||||
*/
|
||||
const decide = (decision: "approved" | "rejected") =>
|
||||
app.post(
|
||||
`/installers/:id/${decision === "approved" ? "approve" : "reject"}`,
|
||||
{
|
||||
schema: {
|
||||
params: idParam,
|
||||
body: z.object({ reason: z.string().trim().max(1000).optional(), notes: z.string().trim().max(5000).optional() }).default({}),
|
||||
response: { 200: decisionDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { u } = await load(request.params.id)
|
||||
if (u.role === "admin") throw new AppError(400, "BAD_REQUEST", "Un admin non può essere installatore")
|
||||
await db.transaction(async (tx) => {
|
||||
await tx
|
||||
.update(installerProfiles)
|
||||
.set({
|
||||
status: decision,
|
||||
approvedAt: decision === "approved" ? new Date() : null,
|
||||
...(request.body.notes !== undefined ? { notes: request.body.notes || null } : {}),
|
||||
})
|
||||
.where(eq(installerProfiles.userId, u.id))
|
||||
if (decision === "approved" && u.role !== "installer") {
|
||||
await tx.update(users).set({ role: "installer" }).where(eq(users.id, u.id))
|
||||
}
|
||||
})
|
||||
const { p, u: updated } = await load(u.id)
|
||||
|
||||
const synced = await trySync(request.log, "gruppo installatori", u.id, () => syncInstallerGroup(app, updated))
|
||||
if (decision === "approved") {
|
||||
await app.email.send({
|
||||
to: updated.email,
|
||||
template: "installer-approved",
|
||||
data: { firstName: updated.firstName, businessName: p.businessName, link: `${config.PRIVATE_APP_URL}/installatori` },
|
||||
})
|
||||
} else {
|
||||
await app.email.send({
|
||||
to: updated.email,
|
||||
template: "installer-rejected",
|
||||
data: { firstName: updated.firstName, businessName: p.businessName, reason: request.body.reason ?? null },
|
||||
})
|
||||
}
|
||||
request.log.info({ installerId: u.id, decision, medusaSynced: synced !== undefined }, "decisione su installatore")
|
||||
return { ...toInstallerAdminDto(p, updated), medusaSynced: synced !== undefined }
|
||||
},
|
||||
)
|
||||
decide("approved")
|
||||
decide("rejected")
|
||||
|
||||
// POST /admin/installers/:id/sync — riallinea il customer group Medusa allo stato attuale (Medusa giù → 503)
|
||||
app.post("/installers/:id/sync", { schema: { params: idParam, response: { 200: z.object({ member: z.boolean() }) } } }, async (request) => {
|
||||
const { u } = await load(request.params.id)
|
||||
return syncInstallerGroup(app, u)
|
||||
})
|
||||
}
|
||||
|
||||
export default adminInstallerRoutes
|
||||
@@ -0,0 +1,292 @@
|
||||
import { and, desc, eq, ilike, inArray, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { documents, type Quote, quoteRequests, quotes, users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { findUserByEmail, findUserById } from "../../services/auth.js"
|
||||
import { receiveUpload, UPLOAD_RATE_LIMIT } from "../../services/documents.js"
|
||||
import { nextQuoteNumber, toQuoteAdminDto, toQuoteRequestAdminDto, todayRome } from "../../services/quotes.js"
|
||||
import { idParam, pageQuery, paginated, quoteAdminDto, quoteStatus } from "../private/schemas.js"
|
||||
|
||||
const requestStatus = z.enum(["new", "contacted", "converted", "closed"])
|
||||
|
||||
const quoteRequestAdminDto = z.object({
|
||||
id: z.uuid(),
|
||||
userId: z.uuid().nullable(),
|
||||
email: z.string(),
|
||||
name: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
province: z.string().nullable(),
|
||||
configuratorInput: z.unknown(),
|
||||
configuratorOutput: z.unknown(),
|
||||
status: requestStatus,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
const date = z.iso.date("Data non valida (AAAA-MM-GG)")
|
||||
|
||||
/** Campi modificabili di un preventivo (senza default: usati anche nel PATCH parziale). */
|
||||
const quoteFields = z.object({
|
||||
title: z.string().trim().min(3).max(200),
|
||||
amountCents: z.number().int().min(0).max(100_000_000_00),
|
||||
validUntil: date.nullable(),
|
||||
})
|
||||
|
||||
const likeEscape = (q: string) => `%${q.replace(/[\\%_]/g, "\\$&")}%`
|
||||
|
||||
/** Richieste di preventivo e preventivi (solo admin). */
|
||||
const adminQuoteRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db, config } = app
|
||||
|
||||
const loadQuoteAdmin = async (quote: Quote) => {
|
||||
const owner = await findUserById(db, quote.userId)
|
||||
return toQuoteAdminDto(quote, owner!)
|
||||
}
|
||||
|
||||
const loadQuote = async (id: string) => {
|
||||
const [q] = await db.select().from(quotes).where(eq(quotes.id, id))
|
||||
if (!q) throw new AppError(404, "QUOTE_NOT_FOUND", "Preventivo non trovato")
|
||||
return q
|
||||
}
|
||||
|
||||
// --- Richieste di preventivo ---
|
||||
|
||||
app.get(
|
||||
"/quote-requests",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({ status: requestStatus.optional(), q: z.string().trim().min(1).max(100).optional() }),
|
||||
response: { 200: paginated(quoteRequestAdminDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, q, limit, offset } = request.query
|
||||
const where = and(
|
||||
status ? eq(quoteRequests.status, status) : undefined,
|
||||
q ? or(ilike(quoteRequests.email, likeEscape(q)), ilike(quoteRequests.name, likeEscape(q))) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db.select().from(quoteRequests).where(where).orderBy(desc(quoteRequests.createdAt)).limit(limit).offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(quoteRequests).where(where),
|
||||
])
|
||||
return { items: rows.map(toQuoteRequestAdminDto), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
"/quote-requests/:id",
|
||||
{ schema: { params: idParam, response: { 200: quoteRequestAdminDto.extend({ quotes: z.array(quoteAdminDto) }) } } },
|
||||
async (request) => {
|
||||
const [r] = await db.select().from(quoteRequests).where(eq(quoteRequests.id, request.params.id))
|
||||
if (!r) throw new AppError(404, "QUOTE_REQUEST_NOT_FOUND", "Richiesta non trovata")
|
||||
const linked = await db.select().from(quotes).where(eq(quotes.quoteRequestId, r.id)).orderBy(desc(quotes.createdAt))
|
||||
return { ...toQuoteRequestAdminDto(r), quotes: await Promise.all(linked.map(loadQuoteAdmin)) }
|
||||
},
|
||||
)
|
||||
|
||||
app.patch(
|
||||
"/quote-requests/:id",
|
||||
{ schema: { params: idParam, body: z.object({ status: requestStatus }), response: { 200: quoteRequestAdminDto } } },
|
||||
async (request) => {
|
||||
const [r] = await db
|
||||
.update(quoteRequests)
|
||||
.set({ status: request.body.status })
|
||||
.where(eq(quoteRequests.id, request.params.id))
|
||||
.returning()
|
||||
if (!r) throw new AppError(404, "QUOTE_REQUEST_NOT_FOUND", "Richiesta non trovata")
|
||||
return toQuoteRequestAdminDto(r)
|
||||
},
|
||||
)
|
||||
|
||||
// --- Preventivi ---
|
||||
|
||||
app.get(
|
||||
"/quotes",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({ status: quoteStatus.optional(), userId: z.uuid().optional() }),
|
||||
response: { 200: paginated(quoteAdminDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, userId, limit, offset } = request.query
|
||||
const where = and(status ? eq(quotes.status, status) : undefined, userId ? eq(quotes.userId, userId) : undefined)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db
|
||||
.select({ q: quotes, u: users })
|
||||
.from(quotes)
|
||||
.innerJoin(users, eq(users.id, quotes.userId))
|
||||
.where(where)
|
||||
.orderBy(desc(quotes.year), desc(quotes.seq))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(quotes).where(where),
|
||||
])
|
||||
return { items: rows.map((r) => toQuoteAdminDto(r.q, r.u)), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/quotes/:id", { schema: { params: idParam, response: { 200: quoteAdminDto } } }, async (request) =>
|
||||
loadQuoteAdmin(await loadQuote(request.params.id)),
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /admin/quotes — nuovo preventivo in bozza, numerato `${anno}-${seq}`.
|
||||
* Destinatario: `userId`, oppure l'account della richiesta (`quoteRequestId`): utente collegato o,
|
||||
* se la richiesta è anonima, l'account con la stessa email (la richiesta viene collegata).
|
||||
* Senza account → 409: il cliente deve registrarsi per vedere il preventivo nell'area clienti.
|
||||
*/
|
||||
app.post(
|
||||
"/quotes",
|
||||
{
|
||||
schema: {
|
||||
body: quoteFields.extend({
|
||||
validUntil: date.nullable().optional(),
|
||||
quoteRequestId: z.uuid().optional(),
|
||||
userId: z.uuid().optional(),
|
||||
}),
|
||||
response: { 201: quoteAdminDto },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const b = request.body
|
||||
if (!b.quoteRequestId && !b.userId) throw new AppError(400, "BAD_REQUEST", "Indica quoteRequestId o userId")
|
||||
|
||||
let userId = b.userId
|
||||
let request_: typeof quoteRequests.$inferSelect | undefined
|
||||
if (b.quoteRequestId) {
|
||||
;[request_] = await db.select().from(quoteRequests).where(eq(quoteRequests.id, b.quoteRequestId))
|
||||
if (!request_) throw new AppError(404, "QUOTE_REQUEST_NOT_FOUND", "Richiesta non trovata")
|
||||
userId ??= request_.userId ?? (await findUserByEmail(db, request_.email))?.id
|
||||
}
|
||||
const owner = userId ? await findUserById(db, userId) : undefined
|
||||
if (!owner) {
|
||||
throw new AppError(409, "CUSTOMER_ACCOUNT_REQUIRED", "Il cliente non ha un account: invitalo a registrarsi con la stessa email")
|
||||
}
|
||||
if (owner.role === "admin") throw new AppError(400, "BAD_REQUEST", "Il destinatario non può essere un admin")
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const n = await nextQuoteNumber(tx, Number(todayRome().slice(0, 4)))
|
||||
const [q] = await tx
|
||||
.insert(quotes)
|
||||
.values({
|
||||
...n,
|
||||
userId: owner.id,
|
||||
quoteRequestId: request_?.id ?? null,
|
||||
title: b.title,
|
||||
amountCents: b.amountCents,
|
||||
validUntil: b.validUntil ?? null,
|
||||
})
|
||||
.returning()
|
||||
if (request_ && !request_.userId) {
|
||||
await tx.update(quoteRequests).set({ userId: owner.id }).where(eq(quoteRequests.id, request_.id))
|
||||
}
|
||||
return q!
|
||||
})
|
||||
request.log.info({ quoteId: created.id, number: created.number }, "preventivo creato")
|
||||
return reply.status(201).send(toQuoteAdminDto(created, owner))
|
||||
},
|
||||
)
|
||||
|
||||
// PATCH /admin/quotes/:id — solo in bozza (un preventivo inviato non si modifica: se ne crea uno nuovo)
|
||||
app.patch(
|
||||
"/quotes/:id",
|
||||
{
|
||||
schema: {
|
||||
params: idParam,
|
||||
body: quoteFields.partial().refine((v) => Object.keys(v).length > 0, "Nessun campo da aggiornare"),
|
||||
response: { 200: quoteAdminDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const q = await loadQuote(request.params.id)
|
||||
if (q.status !== "draft") throw new AppError(409, "QUOTE_NOT_DRAFT", "Si può modificare solo un preventivo in bozza")
|
||||
const [u] = await db.update(quotes).set(request.body).where(eq(quotes.id, q.id)).returning()
|
||||
return loadQuoteAdmin(u!)
|
||||
},
|
||||
)
|
||||
|
||||
// POST /admin/quotes/:id/pdf — allega (o sostituisce) il PDF: documento privato con visibilità admin
|
||||
app.post("/quotes/:id/pdf", { config: { rateLimit: UPLOAD_RATE_LIMIT }, schema: { params: idParam, response: { 200: quoteAdminDto } } }, async (request) => {
|
||||
const q = await loadQuote(request.params.id)
|
||||
if (q.status !== "draft" && q.status !== "sent") {
|
||||
throw new AppError(409, "QUOTE_CLOSED", "Il preventivo ha già ricevuto una risposta")
|
||||
}
|
||||
const upload = await receiveUpload(request, app.storage, {
|
||||
area: "private",
|
||||
prefix: "quotes",
|
||||
allowed: ["pdf"],
|
||||
maxBytes: config.UPLOAD_MAX_BYTES,
|
||||
})
|
||||
let previous: { id: string; filePath: string } | undefined
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [doc] = await tx
|
||||
.insert(documents)
|
||||
.values({
|
||||
title: `Preventivo ${q.number}`,
|
||||
category: "preventivi",
|
||||
visibility: "admin",
|
||||
filePath: upload.key,
|
||||
mimeType: upload.mime,
|
||||
sizeBytes: upload.size,
|
||||
})
|
||||
.returning()
|
||||
const [u] = await tx.update(quotes).set({ pdfDocumentId: doc!.id }).where(eq(quotes.id, q.id)).returning()
|
||||
if (q.pdfDocumentId) {
|
||||
;[previous] = await tx
|
||||
.delete(documents)
|
||||
.where(eq(documents.id, q.pdfDocumentId))
|
||||
.returning({ id: documents.id, filePath: documents.filePath })
|
||||
}
|
||||
return u!
|
||||
}).catch(async (err: unknown) => {
|
||||
await app.storage.delete(upload.area, upload.key)
|
||||
throw err
|
||||
})
|
||||
if (previous) await app.storage.delete("private", previous.filePath).catch((err: unknown) => request.log.error({ err }, "PDF precedente non cancellato"))
|
||||
return loadQuoteAdmin(updated)
|
||||
})
|
||||
|
||||
// POST /admin/quotes/:id/send — invio (o reinvio) al cliente: stato sent, email, richiesta "converted"
|
||||
app.post("/quotes/:id/send", { schema: { params: idParam, response: { 200: quoteAdminDto } } }, async (request) => {
|
||||
const q = await loadQuote(request.params.id)
|
||||
if (q.status !== "draft" && q.status !== "sent") {
|
||||
throw new AppError(409, "QUOTE_CLOSED", "Il preventivo ha già ricevuto una risposta")
|
||||
}
|
||||
if (q.validUntil && q.validUntil < todayRome()) {
|
||||
throw new AppError(409, "QUOTE_EXPIRED", "La data di validità è già passata: aggiornala prima di inviare")
|
||||
}
|
||||
const owner = (await findUserById(db, q.userId))!
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
const [u] = await tx
|
||||
.update(quotes)
|
||||
.set({ status: "sent", sentAt: q.sentAt ?? new Date() })
|
||||
.where(eq(quotes.id, q.id))
|
||||
.returning()
|
||||
if (q.quoteRequestId) {
|
||||
await tx
|
||||
.update(quoteRequests)
|
||||
.set({ status: "converted" })
|
||||
.where(and(eq(quoteRequests.id, q.quoteRequestId), inArray(quoteRequests.status, ["new", "contacted"])))
|
||||
}
|
||||
return u!
|
||||
})
|
||||
await app.email.send({
|
||||
to: owner.email,
|
||||
template: "quote-sent",
|
||||
data: {
|
||||
firstName: owner.firstName,
|
||||
quoteNumber: updated.number,
|
||||
title: updated.title,
|
||||
amountCents: updated.amountCents,
|
||||
validUntil: updated.validUntil,
|
||||
link: `${config.PRIVATE_APP_URL}/preventivi/${updated.id}`,
|
||||
},
|
||||
})
|
||||
request.log.info({ quoteId: updated.id, number: updated.number }, "preventivo inviato")
|
||||
return toQuoteAdminDto(updated, owner)
|
||||
})
|
||||
}
|
||||
|
||||
export default adminQuoteRoutes
|
||||
@@ -0,0 +1,88 @@
|
||||
import { and, asc, eq, gte, ilike, lt, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { showroomBookings } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { SHOWROOM } from "../../showroom/config.js"
|
||||
import { ACTIVE_BOOKING_STATUSES, reserveSeats, toShowroomBookingAdminDto } from "../../services/showroom.js"
|
||||
import { idParam, pageQuery, paginated } from "../private/schemas.js"
|
||||
import { showroomBookingDto, showroomBookingStatus } from "../public/showroom.js"
|
||||
|
||||
const bookingAdminDto = showroomBookingDto.extend({ userId: z.uuid().nullable(), updatedAt: z.string() })
|
||||
|
||||
const isActive = (status: string) => (ACTIVE_BOOKING_STATUSES as readonly string[]).includes(status)
|
||||
|
||||
/** Prenotazioni dello showroom (solo admin): elenco per data e cambio di stato. */
|
||||
const adminShowroomRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db } = app
|
||||
|
||||
// GET /admin/showroom/bookings?status&from&to&q — ordinate per data della visita
|
||||
app.get(
|
||||
"/showroom/bookings",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({
|
||||
status: showroomBookingStatus.optional(),
|
||||
from: z.iso.datetime({ offset: true }).optional(),
|
||||
to: z.iso.datetime({ offset: true }).optional(),
|
||||
q: z.string().trim().min(1).max(100).optional(),
|
||||
}),
|
||||
response: { 200: paginated(bookingAdminDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, from, to, q, limit, offset } = request.query
|
||||
const pattern = q ? `%${q.replace(/[\\%_]/g, "\\$&")}%` : undefined
|
||||
const where = and(
|
||||
status ? eq(showroomBookings.status, status) : undefined,
|
||||
from ? gte(showroomBookings.requestedAt, new Date(from)) : undefined,
|
||||
to ? lt(showroomBookings.requestedAt, new Date(to)) : undefined,
|
||||
pattern ? or(ilike(showroomBookings.email, pattern), ilike(showroomBookings.name, pattern)) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db.select().from(showroomBookings).where(where).orderBy(asc(showroomBookings.requestedAt)).limit(limit).offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(showroomBookings).where(where),
|
||||
])
|
||||
return { items: rows.map(toShowroomBookingAdminDto), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/showroom/bookings/:id", { schema: { params: idParam, response: { 200: bookingAdminDto } } }, async (request) => {
|
||||
const [row] = await db.select().from(showroomBookings).where(eq(showroomBookings.id, request.params.id))
|
||||
if (!row) throw new AppError(404, "BOOKING_NOT_FOUND", "Prenotazione non trovata")
|
||||
return toShowroomBookingAdminDto(row)
|
||||
})
|
||||
|
||||
/**
|
||||
* PATCH /admin/showroom/bookings/:id { status } — riportare una prenotazione annullata o completata in uno stato
|
||||
* attivo ricontrolla la capienza (409 SLOT_FULL). Il passaggio a `confirmed` invia l'email di conferma al cliente.
|
||||
*/
|
||||
app.patch(
|
||||
"/showroom/bookings/:id",
|
||||
{ schema: { params: idParam, body: z.object({ status: showroomBookingStatus }), response: { 200: bookingAdminDto } } },
|
||||
async (request) => {
|
||||
const next = request.body.status
|
||||
const { before, after } = await db.transaction(async (tx) => {
|
||||
const [current] = await tx.select().from(showroomBookings).where(eq(showroomBookings.id, request.params.id)).for("update")
|
||||
if (!current) throw new AppError(404, "BOOKING_NOT_FOUND", "Prenotazione non trovata")
|
||||
if (isActive(next) && !isActive(current.status)) {
|
||||
await reserveSeats(tx, current.requestedAt, current.people, SHOWROOM.capacity, current.id)
|
||||
}
|
||||
const [updated] = await tx.update(showroomBookings).set({ status: next }).where(eq(showroomBookings.id, current.id)).returning()
|
||||
return { before: current, after: updated! }
|
||||
})
|
||||
|
||||
if (next === "confirmed" && before.status !== "confirmed") {
|
||||
await app.email.send({
|
||||
to: after.email,
|
||||
template: "showroom-booking-confirmed",
|
||||
data: { name: after.name, requestedAt: after.requestedAt.toISOString(), people: after.people, notes: after.notes },
|
||||
})
|
||||
}
|
||||
request.log.info({ showroomBookingId: after.id, from: before.status, to: next }, "stato prenotazione showroom aggiornato")
|
||||
return toShowroomBookingAdminDto(after)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default adminShowroomRoutes
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { AdminSummaryDto } from "@iti/shared"
|
||||
import { and, eq, gte, inArray, isNull, ne, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { contactRequests, installerProfiles, quoteRequests, quotes, showroomBookings, tickets } from "../../db/schema/index.js"
|
||||
import { todayRome } from "../../services/quotes.js"
|
||||
import { ACTIVE_BOOKING_STATUSES } from "../../services/showroom.js"
|
||||
|
||||
const summaryDto = z.object({
|
||||
newQuoteRequests: z.number().int(),
|
||||
openTickets: z.number().int(),
|
||||
activeTickets: z.number().int(),
|
||||
pendingInstallers: z.number().int(),
|
||||
upcomingShowroomBookings: z.number().int(),
|
||||
newContactRequests: z.number().int(),
|
||||
sentQuotes: z.number().int(),
|
||||
}) satisfies z.ZodType<AdminSummaryDto>
|
||||
|
||||
const count = sql<number>`count(*)::int`
|
||||
|
||||
/** GET /admin/summary — contatori della dashboard del pannello admin (una query per voce, in parallelo). */
|
||||
const adminSummaryRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db } = app
|
||||
|
||||
app.get("/summary", { schema: { response: { 200: summaryDto } } }, async () => {
|
||||
const n = (rows: { n: number }[]) => rows[0]?.n ?? 0
|
||||
const [requests, open, active, installers, bookings, contacts, sent] = await Promise.all([
|
||||
db.select({ n: count }).from(quoteRequests).where(eq(quoteRequests.status, "new")),
|
||||
db.select({ n: count }).from(tickets).where(eq(tickets.status, "open")),
|
||||
db.select({ n: count }).from(tickets).where(ne(tickets.status, "closed")),
|
||||
db.select({ n: count }).from(installerProfiles).where(eq(installerProfiles.status, "pending")),
|
||||
db
|
||||
.select({ n: count })
|
||||
.from(showroomBookings)
|
||||
.where(and(inArray(showroomBookings.status, ACTIVE_BOOKING_STATUSES), gte(showroomBookings.requestedAt, new Date()))),
|
||||
db.select({ n: count }).from(contactRequests).where(eq(contactRequests.status, "new")),
|
||||
// inviati e non scaduti (quelli scaduti l'API li mostra come "expired")
|
||||
db
|
||||
.select({ n: count })
|
||||
.from(quotes)
|
||||
.where(and(eq(quotes.status, "sent"), or(isNull(quotes.validUntil), gte(quotes.validUntil, todayRome())))),
|
||||
])
|
||||
return {
|
||||
newQuoteRequests: n(requests),
|
||||
openTickets: n(open),
|
||||
activeTickets: n(active),
|
||||
pendingInstallers: n(installers),
|
||||
upcomingShowroomBookings: n(bookings),
|
||||
newContactRequests: n(contacts),
|
||||
sentQuotes: n(sent),
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
export default adminSummaryRoutes
|
||||
@@ -0,0 +1,118 @@
|
||||
import { and, desc, eq, ilike, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { ticketMessages, tickets, users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { ticketMessagesOf, toTicketAdminDto } from "../../services/tickets.js"
|
||||
import { idParam, pageQuery, paginated, ticketAdminDto, ticketMessageDto, ticketPriority, ticketStatus } from "../private/schemas.js"
|
||||
|
||||
const ticketAdminDetailDto = ticketAdminDto.extend({ messages: z.array(ticketMessageDto) })
|
||||
|
||||
/** Gestione ticket di assistenza (solo admin). */
|
||||
const adminTicketRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db, config } = app
|
||||
|
||||
const loadWithCustomer = async (id: string) => {
|
||||
const [row] = await db.select({ t: tickets, u: users }).from(tickets).innerJoin(users, eq(users.id, tickets.userId)).where(eq(tickets.id, id))
|
||||
if (!row) throw new AppError(404, "TICKET_NOT_FOUND", "Ticket non trovato")
|
||||
return row
|
||||
}
|
||||
|
||||
const detail = async (id: string) => {
|
||||
const { t, u } = await loadWithCustomer(id)
|
||||
return { ...toTicketAdminDto(t, u), messages: await ticketMessagesOf(db, t.id) }
|
||||
}
|
||||
|
||||
app.get(
|
||||
"/tickets",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({
|
||||
status: ticketStatus.optional(),
|
||||
priority: ticketPriority.optional(),
|
||||
q: z.string().trim().min(1).max(100).optional(),
|
||||
}),
|
||||
response: { 200: paginated(ticketAdminDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { status, priority, q, limit, offset } = request.query
|
||||
const pattern = q ? `%${q.replace(/[\\%_]/g, "\\$&")}%` : undefined
|
||||
const where = and(
|
||||
status ? eq(tickets.status, status) : undefined,
|
||||
priority ? eq(tickets.priority, priority) : undefined,
|
||||
pattern ? or(ilike(tickets.subject, pattern), ilike(users.email, pattern)) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db
|
||||
.select({ t: tickets, u: users })
|
||||
.from(tickets)
|
||||
.innerJoin(users, eq(users.id, tickets.userId))
|
||||
.where(where)
|
||||
.orderBy(desc(tickets.updatedAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(tickets).innerJoin(users, eq(users.id, tickets.userId)).where(where),
|
||||
])
|
||||
return { items: rows.map((r) => toTicketAdminDto(r.t, r.u)), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/tickets/:id", { schema: { params: idParam, response: { 200: ticketAdminDetailDto } } }, async (request) =>
|
||||
detail(request.params.id),
|
||||
)
|
||||
|
||||
app.patch(
|
||||
"/tickets/:id",
|
||||
{
|
||||
schema: {
|
||||
params: idParam,
|
||||
body: z
|
||||
.object({ status: ticketStatus, priority: ticketPriority })
|
||||
.partial()
|
||||
.refine((v) => Object.keys(v).length > 0, "Nessun campo da aggiornare"),
|
||||
response: { 200: ticketAdminDetailDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const [t] = await db.update(tickets).set(request.body).where(eq(tickets.id, request.params.id)).returning({ id: tickets.id })
|
||||
if (!t) throw new AppError(404, "TICKET_NOT_FOUND", "Ticket non trovato")
|
||||
return detail(t.id)
|
||||
},
|
||||
)
|
||||
|
||||
// POST /admin/tickets/:id/messages — risposta dell'assistenza: email al cliente; stato di default "in attesa del cliente"
|
||||
app.post(
|
||||
"/tickets/:id/messages",
|
||||
{
|
||||
schema: {
|
||||
params: idParam,
|
||||
body: z.object({
|
||||
body: z.string().trim().min(1).max(10_000),
|
||||
status: ticketStatus.default("waiting_customer"),
|
||||
}),
|
||||
response: { 201: ticketAdminDetailDto },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { t, u } = await loadWithCustomer(request.params.id)
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.insert(ticketMessages).values({ ticketId: t.id, authorId: request.user.sub, body: request.body.body })
|
||||
await tx.update(tickets).set({ status: request.body.status }).where(eq(tickets.id, t.id))
|
||||
})
|
||||
await app.email.send({
|
||||
to: u.email,
|
||||
template: "ticket-reply",
|
||||
data: {
|
||||
firstName: u.firstName,
|
||||
ticketSubject: t.subject,
|
||||
message: request.body.body,
|
||||
link: `${config.PRIVATE_APP_URL}/assistenza/${t.id}`,
|
||||
},
|
||||
})
|
||||
return reply.status(201).send(await detail(t.id))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default adminTicketRoutes
|
||||
@@ -0,0 +1,139 @@
|
||||
import type { UserAdminDto } from "@iti/shared"
|
||||
import { and, desc, eq, ilike, isNotNull, isNull, or, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { type InstallerProfile, installerProfiles, type User, users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { revokeAllUserTokens, toUserDto } from "../../services/auth.js"
|
||||
import { syncInstallerGroup, trySync } from "../../services/medusa-sync.js"
|
||||
import { idParam, pageQuery, paginated } from "../private/schemas.js"
|
||||
|
||||
const role = z.enum(["customer", "company", "installer", "admin"])
|
||||
|
||||
const userAdminDto = z.object({
|
||||
id: z.uuid(),
|
||||
email: z.string(),
|
||||
role,
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
companyName: z.string().nullable(),
|
||||
vatNumber: z.string().nullable(),
|
||||
emailVerified: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
disabledAt: z.string().nullable(),
|
||||
hasShopAccount: z.boolean(),
|
||||
installerStatus: z.enum(["pending", "approved", "rejected"]).nullable(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<UserAdminDto>
|
||||
|
||||
/** Esito di disabilita/riabilita: per un installatore anche l'allineamento del gruppo Medusa (listino riservato). */
|
||||
const toggleDto = userAdminDto.extend({ medusaSynced: z.boolean().nullable() })
|
||||
|
||||
const toDto = (u: User, profile: Pick<InstallerProfile, "status"> | null): UserAdminDto => ({
|
||||
...toUserDto(u),
|
||||
disabledAt: u.disabledAt?.toISOString() ?? null,
|
||||
hasShopAccount: u.medusaCustomerId !== null,
|
||||
installerStatus: profile?.status ?? null,
|
||||
updatedAt: u.updatedAt.toISOString(),
|
||||
})
|
||||
|
||||
/** Utenti: ricerca, dettaglio, disabilita/riabilita (solo admin). */
|
||||
const adminUserRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db } = app
|
||||
|
||||
const load = async (id: string) => {
|
||||
const [row] = await db
|
||||
.select({ u: users, status: installerProfiles.status })
|
||||
.from(users)
|
||||
.leftJoin(installerProfiles, eq(installerProfiles.userId, users.id))
|
||||
.where(eq(users.id, id))
|
||||
if (!row) throw new AppError(404, "USER_NOT_FOUND", "Utente non trovato")
|
||||
return { user: row.u, profile: row.status ? { status: row.status } : null }
|
||||
}
|
||||
|
||||
// GET /admin/users?role&status=active|disabled&q (email, nome, cognome, ragione sociale)
|
||||
app.get(
|
||||
"/users",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({
|
||||
role: role.optional(),
|
||||
status: z.enum(["active", "disabled"]).optional(),
|
||||
q: z.string().trim().min(1).max(100).optional(),
|
||||
}),
|
||||
response: { 200: paginated(userAdminDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { role: r, status, q, limit, offset } = request.query
|
||||
const pattern = q ? `%${q.replace(/[\\%_]/g, "\\$&")}%` : undefined
|
||||
const where = and(
|
||||
r ? eq(users.role, r) : undefined,
|
||||
status === "active" ? isNull(users.disabledAt) : status === "disabled" ? isNotNull(users.disabledAt) : undefined,
|
||||
pattern
|
||||
? or(
|
||||
ilike(users.email, pattern),
|
||||
ilike(users.firstName, pattern),
|
||||
ilike(users.lastName, pattern),
|
||||
ilike(users.companyName, pattern),
|
||||
ilike(sql`${users.firstName} || ' ' || ${users.lastName}`, pattern),
|
||||
)
|
||||
: undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db
|
||||
.select({ u: users, status: installerProfiles.status })
|
||||
.from(users)
|
||||
.leftJoin(installerProfiles, eq(installerProfiles.userId, users.id))
|
||||
.where(where)
|
||||
.orderBy(desc(users.createdAt))
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(users).where(where),
|
||||
])
|
||||
return {
|
||||
items: rows.map((row) => toDto(row.u, row.status ? { status: row.status } : null)),
|
||||
total: count?.n ?? 0,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/users/:id", { schema: { params: idParam, response: { 200: userAdminDto } } }, async (request) => {
|
||||
const { user, profile } = await load(request.params.id)
|
||||
return toDto(user, profile)
|
||||
})
|
||||
|
||||
/**
|
||||
* POST /admin/users/:id/disable · /enable
|
||||
* Disabilitare chiude tutte le sessioni (refresh revocati: l'access token in corso scade entro
|
||||
* ACCESS_TOKEN_TTL_SECONDS, ma le route che rileggono l'utente lo rifiutano subito) e, per un installatore,
|
||||
* toglie il customer Medusa dal gruppo del listino riservato. Riabilitare lo riallinea.
|
||||
* Se Medusa non risponde l'operazione resta valida (`medusaSynced: false`; si riallinea al login o da
|
||||
* POST /admin/installers/:id/sync). `medusaSynced: null` = nessun allineamento necessario.
|
||||
*/
|
||||
const toggle = (action: "disable" | "enable") =>
|
||||
app.post(`/users/:id/${action}`, { schema: { params: idParam, response: { 200: toggleDto } } }, async (request) => {
|
||||
const { user } = await load(request.params.id)
|
||||
if (action === "disable" && user.id === request.user.sub) {
|
||||
throw new AppError(409, "SELF_DISABLE", "Non puoi disabilitare il tuo account")
|
||||
}
|
||||
const disabledAt = action === "disable" ? (user.disabledAt ?? new Date()) : null
|
||||
await db.update(users).set({ disabledAt }).where(eq(users.id, user.id))
|
||||
if (action === "disable") await revokeAllUserTokens(db, user.id)
|
||||
|
||||
const { user: updated, profile } = await load(user.id)
|
||||
let medusaSynced: boolean | null = null
|
||||
if (updated.role === "installer") {
|
||||
const synced = await trySync(request.log, "gruppo installatori", updated.id, () => syncInstallerGroup(app, updated))
|
||||
medusaSynced = synced !== undefined
|
||||
}
|
||||
request.log.info({ targetUserId: updated.id, action, medusaSynced }, "stato account aggiornato dall'admin")
|
||||
return { ...toDto(updated, profile), medusaSynced }
|
||||
})
|
||||
toggle("disable")
|
||||
toggle("enable")
|
||||
}
|
||||
|
||||
export default adminUserRoutes
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { FastifyInstance } from "fastify"
|
||||
import installerProfileRoutes from "./private/installer.js"
|
||||
import myConsentRoutes from "./private/consents.js"
|
||||
import meRoutes from "./private/me.js"
|
||||
import myOrderRoutes from "./private/orders.js"
|
||||
import myQuoteRoutes from "./private/quotes.js"
|
||||
import myTicketRoutes from "./private/tickets.js"
|
||||
import authRoutes from "./public/auth.js"
|
||||
import adminRoutes from "./admin/index.js"
|
||||
import blogRoutes from "./public/blog.js"
|
||||
import configuratorRoutes from "./public/configurator.js"
|
||||
import contactRoutes from "./public/contact.js"
|
||||
import documentRoutes from "./public/documents.js"
|
||||
import healthRoutes from "./public/health.js"
|
||||
import shopRoutes from "./public/shop.js"
|
||||
import showroomRoutes from "./public/showroom.js"
|
||||
import medusaWebhookRoutes from "./webhooks/medusa.js"
|
||||
|
||||
/**
|
||||
* Registrazione delle route. Le cartelle separano per area di accesso; i prefissi URL seguono
|
||||
* le API attese dai frontend:
|
||||
* - public/ → senza prefisso (/health, /blog, /shop, /configurator, ...); auth sotto /auth
|
||||
* - private/ → senza prefisso, utente autenticato (/me, /me/orders, ...)
|
||||
* - admin/ → prefisso /admin, solo ruolo admin
|
||||
* - webhooks/ → prefisso /webhooks (es. /webhooks/medusa, firmato HMAC)
|
||||
*/
|
||||
export async function registerRoutes(app: FastifyInstance) {
|
||||
await app.register(healthRoutes)
|
||||
await app.register(authRoutes, { prefix: "/auth" })
|
||||
await app.register(shopRoutes)
|
||||
await app.register(documentRoutes)
|
||||
await app.register(blogRoutes)
|
||||
await app.register(configuratorRoutes)
|
||||
await app.register(contactRoutes)
|
||||
await app.register(showroomRoutes)
|
||||
await app.register(meRoutes)
|
||||
await app.register(myConsentRoutes)
|
||||
await app.register(myQuoteRoutes)
|
||||
await app.register(myOrderRoutes)
|
||||
await app.register(myTicketRoutes)
|
||||
await app.register(installerProfileRoutes)
|
||||
await app.register(adminRoutes, { prefix: "/admin" })
|
||||
await app.register(medusaWebhookRoutes, { prefix: "/webhooks" })
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { consents, users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { revokeAllUserTokens } from "../../services/auth.js"
|
||||
import { listUserConsents, revokeMarketing, toConsentDto } from "../../services/consents.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
import { notifyStaff } from "../../services/notifications.js"
|
||||
import { verifyPassword } from "../../services/password.js"
|
||||
|
||||
const consentDto = z.object({
|
||||
id: z.uuid(),
|
||||
type: z.enum(["privacy", "marketing"]),
|
||||
version: z.string(),
|
||||
source: z.string().nullable(),
|
||||
acceptedAt: z.string(),
|
||||
revokedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
const consentsResponse = z.object({ marketing: z.boolean(), items: z.array(consentDto) })
|
||||
|
||||
/** Consensi GDPR dell'utente e richiesta di cancellazione dell'account. */
|
||||
const myConsentRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, db } = app
|
||||
|
||||
const consentsOf = async (user: typeof users.$inferSelect) => {
|
||||
const rows = await listUserConsents(db, user)
|
||||
return {
|
||||
marketing: rows.some((c) => c.type === "marketing" && !c.revokedAt),
|
||||
items: rows.map(toConsentDto),
|
||||
}
|
||||
}
|
||||
|
||||
// GET /me/consents — registro dei consensi (più recenti prima) e stato attuale del consenso marketing
|
||||
app.get("/me/consents", { onRequest: [app.authenticate], schema: { response: { 200: consentsResponse } } }, async (request) =>
|
||||
consentsOf(await requireActiveUser(db, request)),
|
||||
)
|
||||
|
||||
// POST /me/consents/marketing — iscrizione alle comunicazioni commerciali dall'area clienti (idempotente)
|
||||
app.post(
|
||||
"/me/consents/marketing",
|
||||
{ onRequest: [app.authenticate], schema: { response: { 200: consentsResponse } } },
|
||||
async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const current = await consentsOf(user)
|
||||
if (!current.marketing) {
|
||||
await db.insert(consents).values({
|
||||
userId: user.id,
|
||||
email: user.email,
|
||||
type: "marketing",
|
||||
version: config.PRIVACY_POLICY_VERSION,
|
||||
source: "account",
|
||||
ip: request.ip,
|
||||
})
|
||||
request.log.info({ userId: user.id }, "consenso marketing dato")
|
||||
}
|
||||
return consentsOf(user)
|
||||
},
|
||||
)
|
||||
|
||||
// DELETE /me/consents/marketing — revoca di tutti i consensi marketing attivi (idempotente)
|
||||
app.delete(
|
||||
"/me/consents/marketing",
|
||||
{ onRequest: [app.authenticate], schema: { response: { 200: consentsResponse } } },
|
||||
async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const revoked = await revokeMarketing(db, user)
|
||||
if (revoked) request.log.info({ userId: user.id, revoked }, "consenso marketing revocato")
|
||||
return consentsOf(user)
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /me/delete-account — richiesta di cancellazione (GDPR). Nell'MVP: account disabilitato subito
|
||||
* (login e sessioni non più validi), consenso marketing revocato, staff avvisato; la cancellazione
|
||||
* effettiva dei dati resta manuale. Richiede la password, per non dipendere solo dall'access token.
|
||||
*/
|
||||
app.post(
|
||||
"/me/delete-account",
|
||||
{
|
||||
onRequest: [app.authenticate],
|
||||
config: { rateLimit: { max: config.AUTH_LOGIN_RATE_MAX, timeWindow: "1 minute" } },
|
||||
schema: {
|
||||
body: z.object({ password: z.string().min(1).max(128), reason: z.string().trim().max(2000).optional() }),
|
||||
response: { 202: z.object({ message: z.string() }) },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
if (user.role === "admin") {
|
||||
throw new AppError(409, "ADMIN_ACCOUNT", "Un account amministratore va disattivato da un altro amministratore")
|
||||
}
|
||||
if (!(await verifyPassword(user.passwordHash, request.body.password))) {
|
||||
throw new AppError(401, "INVALID_CREDENTIALS", "Password non corretta")
|
||||
}
|
||||
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.update(users).set({ disabledAt: new Date() }).where(eq(users.id, user.id))
|
||||
await revokeMarketing(tx, user)
|
||||
})
|
||||
await revokeAllUserTokens(db, user.id)
|
||||
|
||||
await notifyStaff(app, {
|
||||
title: `Richiesta di cancellazione account - ${user.email}`,
|
||||
intro: "Un utente ha chiesto la cancellazione del proprio account. L'account è già disabilitato: la cancellazione dei dati va completata a mano.",
|
||||
rows: [
|
||||
{ label: "Utente", value: `${user.firstName} ${user.lastName}` },
|
||||
{ label: "Email", value: user.email },
|
||||
{ label: "Ruolo", value: user.role },
|
||||
{ label: "Cliente Medusa", value: user.medusaCustomerId ?? "-" },
|
||||
{ label: "Motivo", value: request.body.reason ?? "-" },
|
||||
],
|
||||
url: `${config.PRIVATE_APP_URL}/admin/utenti/${user.id}`,
|
||||
})
|
||||
request.log.warn({ userId: user.id }, "richiesta di cancellazione account: utente disabilitato")
|
||||
return reply.status(202).send({ message: "Richiesta ricevuta: l'account è stato disattivato e i tuoi dati verranno cancellati" })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default myConsentRoutes
|
||||
@@ -0,0 +1,93 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { installerProfiles } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
import { isInItaly } from "../../services/geo.js"
|
||||
import { toInstallerProfileDto } from "../../services/installers.js"
|
||||
|
||||
export const geoPoint = z
|
||||
.object({
|
||||
lat: z.number().min(-90).max(90),
|
||||
lng: z.number().min(-180).max(180),
|
||||
})
|
||||
.refine(isInItaly, "Coordinate fuori dall'Italia")
|
||||
|
||||
export const installerProfileDto = z.object({
|
||||
userId: z.uuid(),
|
||||
businessName: z.string(),
|
||||
vatNumber: z.string().nullable(),
|
||||
phone: z.string().nullable(),
|
||||
address: z.string().nullable(),
|
||||
location: z.object({ lat: z.number(), lng: z.number() }).nullable(),
|
||||
serviceRadiusKm: z.number().int(),
|
||||
certifications: z.array(z.string()),
|
||||
status: z.enum(["pending", "approved", "rejected"]),
|
||||
approvedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
const nullableText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => v || null)
|
||||
.nullable()
|
||||
|
||||
/**
|
||||
* Profilo dell'installatore (ruolo installer, qualunque stato). Le coordinate arrivano da un selettore
|
||||
* su mappa nel frontend: nessun geocoding lato server. Lo stato di approvazione e le note interne
|
||||
* non sono modificabili da qui.
|
||||
*/
|
||||
const installerProfileRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db } = app
|
||||
app.addHook("onRequest", app.requireRole("installer"))
|
||||
|
||||
app.get("/installer/profile", { schema: { response: { 200: installerProfileDto } } }, async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const [p] = await db.select().from(installerProfiles).where(eq(installerProfiles.userId, user.id))
|
||||
if (!p) throw new AppError(404, "INSTALLER_PROFILE_NOT_FOUND", "Profilo installatore non trovato")
|
||||
return toInstallerProfileDto(p)
|
||||
})
|
||||
|
||||
// PUT /installer/profile — sostituisce i dati del profilo (crea il profilo se manca, in stato pending)
|
||||
app.put(
|
||||
"/installer/profile",
|
||||
{
|
||||
schema: {
|
||||
body: z.object({
|
||||
businessName: z.string().trim().min(2).max(200),
|
||||
vatNumber: nullableText(20),
|
||||
phone: nullableText(30),
|
||||
address: nullableText(300),
|
||||
location: geoPoint.nullable(),
|
||||
serviceRadiusKm: z.number().int().min(1).max(300),
|
||||
certifications: z.array(z.string().trim().min(1).max(100)).max(20),
|
||||
}),
|
||||
response: { 200: installerProfileDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const b = request.body
|
||||
const values = {
|
||||
businessName: b.businessName,
|
||||
vatNumber: b.vatNumber,
|
||||
phone: b.phone,
|
||||
address: b.address,
|
||||
location: b.location,
|
||||
serviceRadiusKm: b.serviceRadiusKm,
|
||||
certifications: [...new Set(b.certifications)],
|
||||
}
|
||||
const [p] = await db
|
||||
.insert(installerProfiles)
|
||||
.values({ userId: user.id, ...values })
|
||||
.onConflictDoUpdate({ target: installerProfiles.userId, set: values })
|
||||
.returning()
|
||||
return toInstallerProfileDto(p!)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default installerProfileRoutes
|
||||
@@ -0,0 +1,76 @@
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { findUserById, toUserDto } from "../../services/auth.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
|
||||
const name = z.string().trim().min(1, "Campo obbligatorio").max(100)
|
||||
const optionalText = (min: number, max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.transform((v) => (v === "" ? null : v))
|
||||
.refine((v) => v === null || v.length >= min, `Almeno ${min} caratteri`)
|
||||
.nullable()
|
||||
|
||||
/** Senza default (vedi la nota su zod 4 in CLAUDE.md): i campi non inviati restano invariati. */
|
||||
const patchBody = z
|
||||
.object({
|
||||
firstName: name,
|
||||
lastName: name,
|
||||
companyName: optionalText(1, 200),
|
||||
vatNumber: optionalText(5, 20),
|
||||
})
|
||||
.partial()
|
||||
|
||||
/**
|
||||
* GET /me — profilo dell'utente autenticato (letto dal DB, non dal token).
|
||||
* PATCH /me — dati anagrafici. Email e ruolo non si cambiano da qui. Ragione sociale e P.IVA solo per
|
||||
* aziende e installatori, con le stesse regole della registrazione (azienda: entrambe obbligatorie;
|
||||
* installatore: ragione sociale obbligatoria).
|
||||
* Il nome del customer Medusa non viene aggiornato: resta quello della registrazione (gli ordini usano
|
||||
* comunque i dati dell'indirizzo).
|
||||
*/
|
||||
const meRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get("/me", { onRequest: [app.authenticate] }, async (request) => {
|
||||
const user = await findUserById(app.db, request.user.sub)
|
||||
if (!user || user.disabledAt) throw new AppError(401, "UNAUTHORIZED", "Account non disponibile")
|
||||
return toUserDto(user)
|
||||
})
|
||||
|
||||
app.patch("/me", { onRequest: [app.authenticate], schema: { body: patchBody } }, async (request) => {
|
||||
const user = await requireActiveUser(app.db, request)
|
||||
const body = request.body
|
||||
const touchesCompany = body.companyName !== undefined || body.vatNumber !== undefined
|
||||
const details: { path: string; message: string }[] = []
|
||||
|
||||
if (touchesCompany && (user.role === "customer" || user.role === "admin")) {
|
||||
details.push({ path: "body.companyName", message: "Dati aziendali non previsti per questo account" })
|
||||
}
|
||||
const companyName = body.companyName === undefined ? user.companyName : body.companyName
|
||||
const vatNumber = body.vatNumber === undefined ? user.vatNumber : body.vatNumber
|
||||
if ((user.role === "company" || user.role === "installer") && !companyName) {
|
||||
details.push({ path: "body.companyName", message: "Ragione sociale obbligatoria" })
|
||||
}
|
||||
if (user.role === "company" && !vatNumber) {
|
||||
details.push({ path: "body.vatNumber", message: "Partita IVA obbligatoria per le aziende" })
|
||||
}
|
||||
if (details.length > 0) throw new AppError(400, "VALIDATION_ERROR", details[0]!.message, details)
|
||||
|
||||
const [updated] = await app.db
|
||||
.update(users)
|
||||
.set({
|
||||
...(body.firstName !== undefined ? { firstName: body.firstName } : {}),
|
||||
...(body.lastName !== undefined ? { lastName: body.lastName } : {}),
|
||||
...(touchesCompany ? { companyName, vatNumber } : {}),
|
||||
})
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
return toUserDto(updated!)
|
||||
})
|
||||
}
|
||||
|
||||
export default meRoutes
|
||||
@@ -0,0 +1,147 @@
|
||||
import type { MyOrderDetailDto, MyOrderDto } from "@iti/shared"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
import { ensureMedusaCustomer } from "../../services/medusa-sync.js"
|
||||
import { shippingAddressLines } from "../../services/medusa-webhooks.js"
|
||||
import { toCents } from "../../services/shop.js"
|
||||
import { paginated } from "./schemas.js"
|
||||
|
||||
const orderItem = z.object({
|
||||
title: z.string(),
|
||||
sku: z.string().nullable(),
|
||||
thumbnail: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number().int(),
|
||||
total: z.number().int(),
|
||||
})
|
||||
|
||||
const orderDto = z.object({
|
||||
number: z.number().int(),
|
||||
status: z.string(),
|
||||
paymentStatus: z.string(),
|
||||
fulfillmentStatus: z.string(),
|
||||
createdAt: z.string(),
|
||||
totals: z.object({ items: z.number().int(), shipping: z.number().int(), tax: z.number().int(), total: z.number().int() }),
|
||||
items: z.array(orderItem),
|
||||
}) satisfies z.ZodType<MyOrderDto>
|
||||
|
||||
const orderDetailDto = orderDto.extend({
|
||||
totals: z.object({
|
||||
items: z.number().int(),
|
||||
shipping: z.number().int(),
|
||||
discount: z.number().int(),
|
||||
tax: z.number().int(),
|
||||
total: z.number().int(),
|
||||
}),
|
||||
shippingAddress: z.array(z.string()),
|
||||
shippingMethods: z.array(z.string()),
|
||||
tracking: z.array(z.object({ number: z.string(), url: z.string().nullable() })),
|
||||
}) satisfies z.ZodType<MyOrderDetailDto>
|
||||
|
||||
/**
|
||||
* Ordini dell'utente letti da Medusa tramite il suo customer: comprendono gli ordini guest già collegati
|
||||
* con il claim. Medusa giù → 503 SHOP_UNAVAILABLE. Nessun id Medusa nelle risposte: l'ordine si identifica
|
||||
* con il numero (display_id).
|
||||
*/
|
||||
const myOrderRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.addHook("onRequest", app.authenticate)
|
||||
|
||||
// GET /me/orders — elenco, dal più recente (/internal/customers/:id/orders)
|
||||
app.get(
|
||||
"/me/orders",
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
}),
|
||||
response: { 200: paginated(orderDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const user = await requireActiveUser(app.db, request)
|
||||
const customerId = await ensureMedusaCustomer(app, user)
|
||||
const { limit, offset } = request.query
|
||||
const res = await app.medusa.internal.customerOrders(customerId, limit, offset)
|
||||
return {
|
||||
items: res.orders.map((o) => ({
|
||||
number: o.display_id,
|
||||
status: o.status,
|
||||
paymentStatus: o.payment_status ?? "not_paid",
|
||||
fulfillmentStatus: o.fulfillment_status ?? "not_fulfilled",
|
||||
createdAt: new Date(o.created_at).toISOString(),
|
||||
totals: { items: toCents(o.item_total), shipping: toCents(o.shipping_total), tax: toCents(o.tax_total), total: toCents(o.total) },
|
||||
items: (o.items ?? []).map((i) => ({
|
||||
title: i.title,
|
||||
sku: i.variant_sku ?? null,
|
||||
thumbnail: i.thumbnail ?? null,
|
||||
quantity: i.quantity,
|
||||
unitPrice: toCents(i.unit_price),
|
||||
total: toCents(i.total ?? i.unit_price * i.quantity),
|
||||
})),
|
||||
})),
|
||||
total: res.count,
|
||||
limit,
|
||||
offset,
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* GET /me/orders/:number — dettaglio. La proprietà è verificata cercando il numero tra gli ordini del
|
||||
* customer dell'utente (filtro `display_id` della route interna); poi l'ordine viene riletto per id con
|
||||
* indirizzo, spedizione e tracking. Ordine di un altro cliente o inesistente → 404.
|
||||
*/
|
||||
app.get(
|
||||
"/me/orders/:number",
|
||||
{
|
||||
schema: {
|
||||
params: z.object({ number: z.coerce.number().int().min(1).max(2_147_483_647) }),
|
||||
response: { 200: orderDetailDto },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const user = await requireActiveUser(app.db, request)
|
||||
const customerId = await ensureMedusaCustomer(app, user)
|
||||
const list = await app.medusa.internal.customerOrders(customerId, 1, 0, request.params.number)
|
||||
const summary = list.orders.find((o) => o.display_id === request.params.number)
|
||||
if (!summary) throw new AppError(404, "ORDER_NOT_FOUND", "Ordine non trovato")
|
||||
|
||||
const { order } = await app.medusa.internal.getOrder(summary.id)
|
||||
if (order.customer_id !== customerId) throw new AppError(404, "ORDER_NOT_FOUND", "Ordine non trovato")
|
||||
return {
|
||||
number: order.display_id,
|
||||
status: order.status,
|
||||
paymentStatus: order.payment_status ?? summary.payment_status ?? "not_paid",
|
||||
fulfillmentStatus: order.fulfillment_status ?? summary.fulfillment_status ?? "not_fulfilled",
|
||||
createdAt: new Date(order.created_at).toISOString(),
|
||||
totals: {
|
||||
items: toCents(order.item_total),
|
||||
shipping: toCents(order.shipping_total),
|
||||
discount: toCents(order.discount_total ?? 0),
|
||||
tax: toCents(order.tax_total),
|
||||
total: toCents(order.total),
|
||||
},
|
||||
items: (order.items ?? []).map((i) => ({
|
||||
title: [i.product_title ?? i.title, i.variant_title].filter(Boolean).join(" - "),
|
||||
sku: i.variant_sku ?? null,
|
||||
thumbnail: i.thumbnail ?? null,
|
||||
quantity: i.quantity,
|
||||
unitPrice: toCents(i.unit_price),
|
||||
total: toCents(i.total ?? i.unit_price * i.quantity),
|
||||
})),
|
||||
shippingAddress: shippingAddressLines(order),
|
||||
shippingMethods: (order.shipping_methods ?? []).map((m) => m.name).filter((n): n is string => Boolean(n)),
|
||||
tracking: (order.fulfillments ?? []).flatMap((f) =>
|
||||
(f.labels ?? [])
|
||||
.filter((l) => l.tracking_number)
|
||||
.map((l) => ({ number: l.tracking_number!, url: l.tracking_url ?? null })),
|
||||
),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default myOrderRoutes
|
||||
@@ -0,0 +1,93 @@
|
||||
import { and, desc, eq, ne, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { formatCents } from "../../emails/html.js"
|
||||
import { documents, quotes } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
import { sendDocumentFile } from "../../services/documents.js"
|
||||
import { notifyStaff } from "../../services/notifications.js"
|
||||
import { effectiveStatus, toQuoteDto } from "../../services/quotes.js"
|
||||
import { idParam, pageQuery, paginated, quoteDto } from "./schemas.js"
|
||||
|
||||
/**
|
||||
* Preventivi del cliente. Ownership in ogni query (user_id = utente corrente): un preventivo
|
||||
* altrui o ancora in bozza risponde 404, come uno inesistente (non si rivela che esiste).
|
||||
*/
|
||||
const myQuoteRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db, config } = app
|
||||
app.addHook("onRequest", app.authenticate)
|
||||
|
||||
/** Visibili al cliente: i suoi, esclusi i draft. */
|
||||
const mine = (userId: string, id?: string) =>
|
||||
and(eq(quotes.userId, userId), ne(quotes.status, "draft"), id ? eq(quotes.id, id) : undefined)
|
||||
|
||||
app.get("/me/quotes", { schema: { querystring: pageQuery, response: { 200: paginated(quoteDto) } } }, async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const { limit, offset } = request.query
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db.select().from(quotes).where(mine(user.id)).orderBy(desc(quotes.createdAt)).limit(limit).offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(quotes).where(mine(user.id)),
|
||||
])
|
||||
return { items: rows.map(toQuoteDto), total: count?.n ?? 0, limit, offset }
|
||||
})
|
||||
|
||||
app.get("/me/quotes/:id", { schema: { params: idParam, response: { 200: quoteDto } } }, async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const [q] = await db.select().from(quotes).where(mine(user.id, request.params.id))
|
||||
if (!q) throw new AppError(404, "QUOTE_NOT_FOUND", "Preventivo non trovato")
|
||||
return toQuoteDto(q)
|
||||
})
|
||||
|
||||
// GET /me/quotes/:id/pdf — PDF allegato (documento con visibilità admin: accessibile solo da qui)
|
||||
app.get("/me/quotes/:id/pdf", { schema: { params: idParam } }, async (request, reply) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const [row] = await db
|
||||
.select({ quote: quotes, doc: documents })
|
||||
.from(quotes)
|
||||
.innerJoin(documents, eq(documents.id, quotes.pdfDocumentId))
|
||||
.where(mine(user.id, request.params.id))
|
||||
if (!row) throw new AppError(404, "QUOTE_NOT_FOUND", "PDF del preventivo non disponibile")
|
||||
return sendDocumentFile(request, reply, app.storage, row.doc, `Preventivo ${row.quote.number}.pdf`)
|
||||
})
|
||||
|
||||
/** Accetta/rifiuta: solo un preventivo inviato e non scaduto (lock della riga contro doppie risposte). */
|
||||
const respond = (decision: "accepted" | "rejected") =>
|
||||
app.post(`/me/quotes/:id/${decision === "accepted" ? "accept" : "reject"}`, { schema: { params: idParam, response: { 200: quoteDto } } }, async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
// Gli esiti negativi escono come valore, non come eccezione: un throw nella transazione
|
||||
// annullerebbe anche il passaggio a "expired"
|
||||
const outcome = await db.transaction(async (tx) => {
|
||||
const [q] = await tx.select().from(quotes).where(mine(user.id, request.params.id)).for("update")
|
||||
if (!q) return { kind: "not_found" } as const
|
||||
const status = effectiveStatus(q)
|
||||
if (status === "expired") {
|
||||
await tx.update(quotes).set({ status: "expired" }).where(eq(quotes.id, q.id))
|
||||
return { kind: "expired" } as const
|
||||
}
|
||||
if (status !== "sent") return { kind: "not_pending" } as const
|
||||
const [u] = await tx.update(quotes).set({ status: decision }).where(eq(quotes.id, q.id)).returning()
|
||||
return { kind: "ok", quote: u! } as const
|
||||
})
|
||||
if (outcome.kind === "not_found") throw new AppError(404, "QUOTE_NOT_FOUND", "Preventivo non trovato")
|
||||
if (outcome.kind === "expired") throw new AppError(409, "QUOTE_EXPIRED", "Il preventivo è scaduto: contattaci per aggiornarlo")
|
||||
if (outcome.kind === "not_pending") throw new AppError(409, "QUOTE_NOT_PENDING", "Il preventivo ha già ricevuto una risposta")
|
||||
const updated = outcome.quote
|
||||
|
||||
request.log.info({ quoteId: updated.id, decision }, "risposta del cliente al preventivo")
|
||||
await notifyStaff(app, {
|
||||
title: `Preventivo ${updated.number} ${decision === "accepted" ? "accettato" : "rifiutato"}`,
|
||||
intro: `Il cliente ha ${decision === "accepted" ? "accettato" : "rifiutato"} il preventivo ${updated.number}.`,
|
||||
rows: [
|
||||
{ label: "Cliente", value: `${user.firstName} ${user.lastName} (${user.email})` },
|
||||
{ label: "Oggetto", value: updated.title },
|
||||
{ label: "Importo", value: formatCents(updated.amountCents) },
|
||||
],
|
||||
url: `${config.PRIVATE_APP_URL}/admin/preventivi/${updated.id}`,
|
||||
})
|
||||
return toQuoteDto(updated)
|
||||
})
|
||||
respond("accepted")
|
||||
respond("rejected")
|
||||
}
|
||||
|
||||
export default myQuoteRoutes
|
||||
@@ -0,0 +1,62 @@
|
||||
import type { QuoteAdminDto, QuoteDto, TicketAdminDto, TicketDetailDto, TicketDto } from "@iti/shared"
|
||||
import { z } from "zod"
|
||||
|
||||
/** Schemi di risposta condivisi tra area clienti e admin (stessa forma dei DTO di @iti/shared). */
|
||||
|
||||
export const quoteStatus = z.enum(["draft", "sent", "accepted", "rejected", "expired"])
|
||||
export const ticketStatus = z.enum(["open", "in_progress", "waiting_customer", "closed"])
|
||||
export const ticketPriority = z.enum(["low", "normal", "high", "urgent"])
|
||||
|
||||
export const quoteDto = z.object({
|
||||
id: z.uuid(),
|
||||
quoteRequestId: z.uuid().nullable(),
|
||||
number: z.string(),
|
||||
title: z.string(),
|
||||
status: quoteStatus,
|
||||
amountCents: z.number().int(),
|
||||
hasPdf: z.boolean(),
|
||||
validUntil: z.string().nullable(),
|
||||
sentAt: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
}) satisfies z.ZodType<QuoteDto>
|
||||
|
||||
export const quoteAdminDto = quoteDto.extend({
|
||||
userId: z.uuid(),
|
||||
customer: z.object({ email: z.string(), firstName: z.string(), lastName: z.string(), companyName: z.string().nullable() }),
|
||||
pdfDocumentId: z.uuid().nullable(),
|
||||
}) satisfies z.ZodType<QuoteAdminDto>
|
||||
|
||||
export const ticketDto = z.object({
|
||||
id: z.uuid(),
|
||||
subject: z.string(),
|
||||
status: ticketStatus,
|
||||
priority: ticketPriority,
|
||||
createdAt: z.string(),
|
||||
updatedAt: z.string(),
|
||||
}) satisfies z.ZodType<TicketDto>
|
||||
|
||||
export const ticketMessageDto = z.object({
|
||||
id: z.uuid(),
|
||||
ticketId: z.uuid(),
|
||||
authorId: z.uuid(),
|
||||
fromStaff: z.boolean(),
|
||||
body: z.string(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
export const ticketDetailDto = ticketDto.extend({ messages: z.array(ticketMessageDto) }) satisfies z.ZodType<TicketDetailDto>
|
||||
|
||||
export const ticketAdminDto = ticketDto.extend({
|
||||
userId: z.uuid(),
|
||||
customer: z.object({ email: z.string(), firstName: z.string(), lastName: z.string() }),
|
||||
}) satisfies z.ZodType<TicketAdminDto>
|
||||
|
||||
export const paginated = <T extends z.ZodType>(item: T) =>
|
||||
z.object({ items: z.array(item), total: z.number().int(), limit: z.number().int(), offset: z.number().int() })
|
||||
|
||||
export const pageQuery = z.object({
|
||||
limit: z.coerce.number().int().min(1).max(100).default(20),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
})
|
||||
|
||||
export const idParam = z.object({ id: z.uuid() })
|
||||
@@ -0,0 +1,113 @@
|
||||
import { and, desc, eq, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { ticketMessages, tickets } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { requireActiveUser } from "../../services/current-user.js"
|
||||
import { notifyStaff } from "../../services/notifications.js"
|
||||
import { ticketDetail, toTicketDto } from "../../services/tickets.js"
|
||||
import { idParam, pageQuery, paginated, ticketDetailDto, ticketDto } from "./schemas.js"
|
||||
|
||||
const body = z.string().trim().min(1, "Messaggio obbligatorio").max(5000)
|
||||
|
||||
/** Assistenza: ticket del cliente. Ownership in ogni query; ticket altrui → 404. */
|
||||
const myTicketRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { db, config } = app
|
||||
app.addHook("onRequest", app.authenticate)
|
||||
|
||||
const loadOwn = async (userId: string, id: string) => {
|
||||
const [t] = await db.select().from(tickets).where(and(eq(tickets.id, id), eq(tickets.userId, userId)))
|
||||
if (!t) throw new AppError(404, "TICKET_NOT_FOUND", "Richiesta di assistenza non trovata")
|
||||
return t
|
||||
}
|
||||
|
||||
app.get(
|
||||
"/me/tickets",
|
||||
{
|
||||
schema: {
|
||||
querystring: pageQuery.extend({ status: z.enum(["open", "in_progress", "waiting_customer", "closed"]).optional() }),
|
||||
response: { 200: paginated(ticketDto) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const { limit, offset, status } = request.query
|
||||
const where = and(eq(tickets.userId, user.id), status ? eq(tickets.status, status) : undefined)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
db.select().from(tickets).where(where).orderBy(desc(tickets.updatedAt)).limit(limit).offset(offset),
|
||||
db.select({ n: sql<number>`count(*)::int` }).from(tickets).where(where),
|
||||
])
|
||||
return { items: rows.map(toTicketDto), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
// POST /me/tickets — nuovo ticket con il primo messaggio (priorità urgent riservata all'assistenza)
|
||||
app.post(
|
||||
"/me/tickets",
|
||||
{
|
||||
config: { rateLimit: { max: 10, timeWindow: "1 hour" } },
|
||||
schema: {
|
||||
body: z.object({
|
||||
subject: z.string().trim().min(3, "Oggetto troppo corto").max(200),
|
||||
body,
|
||||
priority: z.enum(["low", "normal", "high"]).default("normal"),
|
||||
}),
|
||||
response: { 201: ticketDetailDto },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [t] = await tx
|
||||
.insert(tickets)
|
||||
.values({ userId: user.id, subject: request.body.subject, priority: request.body.priority })
|
||||
.returning()
|
||||
await tx.insert(ticketMessages).values({ ticketId: t!.id, authorId: user.id, body: request.body.body })
|
||||
return t!
|
||||
})
|
||||
await notifyStaff(app, {
|
||||
title: `Nuovo ticket: ${created.subject}`,
|
||||
intro: `${user.firstName} ${user.lastName} ha aperto una richiesta di assistenza.`,
|
||||
rows: [
|
||||
{ label: "Cliente", value: user.email },
|
||||
{ label: "Priorità", value: created.priority },
|
||||
{ label: "Messaggio", value: request.body.body },
|
||||
],
|
||||
url: `${config.PRIVATE_APP_URL}/admin/ticket/${created.id}`,
|
||||
})
|
||||
return reply.status(201).send(await ticketDetail(db, created))
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/me/tickets/:id", { schema: { params: idParam, response: { 200: ticketDetailDto } } }, async (request) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
return ticketDetail(db, await loadOwn(user.id, request.params.id))
|
||||
})
|
||||
|
||||
// POST /me/tickets/:id/messages — risposta del cliente: il ticket torna "open" (anche se chiuso)
|
||||
app.post(
|
||||
"/me/tickets/:id/messages",
|
||||
{
|
||||
config: { rateLimit: { max: 30, timeWindow: "1 hour" } },
|
||||
schema: { params: idParam, body: z.object({ body }), response: { 201: ticketDetailDto } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await requireActiveUser(db, request)
|
||||
const ticket = await loadOwn(user.id, request.params.id)
|
||||
const updated = await db.transaction(async (tx) => {
|
||||
await tx.insert(ticketMessages).values({ ticketId: ticket.id, authorId: user.id, body: request.body.body })
|
||||
const [t] = await tx.update(tickets).set({ status: "open" }).where(eq(tickets.id, ticket.id)).returning()
|
||||
return t!
|
||||
})
|
||||
await notifyStaff(app, {
|
||||
title: `Risposta del cliente: ${updated.subject}`,
|
||||
intro: `${user.firstName} ${user.lastName} ha risposto alla richiesta di assistenza.`,
|
||||
rows: [{ label: "Messaggio", value: request.body.body }],
|
||||
url: `${config.PRIVATE_APP_URL}/admin/ticket/${updated.id}`,
|
||||
})
|
||||
return reply.status(201).send(await ticketDetail(db, updated))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default myTicketRoutes
|
||||
@@ -0,0 +1,423 @@
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { consents, emailVerificationTokens, installerProfiles, passwordResetTokens, users } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import {
|
||||
checkCredentials,
|
||||
consumeOneTimeToken,
|
||||
createOneTimeToken,
|
||||
EMAIL_VERIFICATION_TTL_MS,
|
||||
findUserByEmail,
|
||||
findUserById,
|
||||
issueRefreshToken,
|
||||
normalizeEmail,
|
||||
PASSWORD_RESET_TTL_MS,
|
||||
revokeAllUserTokens,
|
||||
revokeByToken,
|
||||
rotateRefreshToken,
|
||||
toUserDto,
|
||||
} from "../../services/auth.js"
|
||||
import { clearCartCookie, readCartCookie } from "../../services/cart-cookie.js"
|
||||
import { claimGuestOrders, ensureMedusaCustomer, syncInstallerGroup, trySync } from "../../services/medusa-sync.js"
|
||||
import { hashPassword, verifyPassword } from "../../services/password.js"
|
||||
import { linkAnonymousQuoteRequests } from "../../services/quotes.js"
|
||||
|
||||
|
||||
/** Nome del cookie con il refresh token (path /auth: inviato solo alle route di questo file). */
|
||||
export const REFRESH_COOKIE = "iti_rt"
|
||||
const COOKIE_PATH = "/auth"
|
||||
/**
|
||||
* Indizio di sessione per i frontend (non httpOnly, valore "1", nessun segreto): accompagna il refresh token
|
||||
* con la stessa scadenza e lo stesso dominio, ma con path "/" così è leggibile da `document.cookie`.
|
||||
* L'app pubblica chiama /auth/refresh solo se c'è: niente refresh (e niente 401) per ogni visitatore anonimo.
|
||||
* È solo un'ottimizzazione: l'autorità resta il refresh token.
|
||||
*/
|
||||
export const SESSION_HINT_COOKIE = "iti_session"
|
||||
|
||||
|
||||
// Prima normalizza (trim + minuscolo), poi valida: z.email().trim() validerebbe prima di togliere gli spazi
|
||||
const email = z.string().trim().toLowerCase().pipe(z.email("Email non valida"))
|
||||
const password = z.string().min(10, "La password deve avere almeno 10 caratteri").max(128)
|
||||
const token = z.string().min(20).max(200)
|
||||
const name = z.string().trim().min(1).max(100)
|
||||
|
||||
const userDto = z.object({
|
||||
id: z.uuid(),
|
||||
email: z.string(),
|
||||
role: z.enum(["customer", "company", "installer", "admin"]),
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
companyName: z.string().nullable(),
|
||||
vatNumber: z.string().nullable(),
|
||||
emailVerified: z.boolean(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
const sessionResponse = z.object({
|
||||
accessToken: z.string(),
|
||||
expiresIn: z.number().int(),
|
||||
user: userDto,
|
||||
})
|
||||
|
||||
const messageResponse = z.object({ message: z.string() })
|
||||
|
||||
const registerBody = z
|
||||
.object({
|
||||
email,
|
||||
password,
|
||||
firstName: name,
|
||||
lastName: name,
|
||||
/** customer (default), company (richiede ragione sociale + P.IVA), installer (richiede ragione sociale). */
|
||||
accountType: z.enum(["customer", "company", "installer"]).optional(),
|
||||
companyName: z.string().trim().min(1).max(200).optional(),
|
||||
vatNumber: z.string().trim().min(5).max(20).optional(),
|
||||
phone: z.string().trim().min(5).max(30).optional(),
|
||||
acceptPrivacy: z.literal(true, { error: "Il consenso al trattamento dei dati è obbligatorio" }),
|
||||
acceptMarketing: z.boolean().optional(),
|
||||
})
|
||||
.superRefine((body, ctx) => {
|
||||
if (body.accountType === "company" && !(body.companyName && body.vatNumber)) {
|
||||
ctx.addIssue({ code: "custom", path: ["vatNumber"], message: "Ragione sociale e P.IVA obbligatorie per le aziende" })
|
||||
}
|
||||
if (body.accountType === "installer" && !body.companyName) {
|
||||
ctx.addIssue({ code: "custom", path: ["companyName"], message: "Ragione sociale obbligatoria per gli installatori" })
|
||||
}
|
||||
})
|
||||
|
||||
const REGISTER_MESSAGE =
|
||||
"Registrazione ricevuta. Se l'indirizzo è corretto riceverai un'email per confermarlo; poi potrai accedere."
|
||||
const FORGOT_MESSAGE = "Se l'indirizzo è registrato riceverai un'email con le istruzioni per reimpostare la password."
|
||||
|
||||
const authRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, db } = app
|
||||
|
||||
/** Limiti stretti per IP sulle route sensibili (oltre a quello globale). */
|
||||
const STRICT_LIMIT = { max: config.AUTH_LOGIN_RATE_MAX, timeWindow: "1 minute" }
|
||||
const EMAIL_LIMIT = { max: config.AUTH_EMAIL_RATE_MAX, timeWindow: "15 minutes" }
|
||||
|
||||
const clientMeta = (request: FastifyRequest) => ({
|
||||
userAgent: request.headers["user-agent"],
|
||||
ip: request.ip,
|
||||
})
|
||||
|
||||
const cookieDomain = config.AUTH_COOKIE_DOMAIN ? { domain: config.AUTH_COOKIE_DOMAIN } : {}
|
||||
|
||||
const setRefreshCookie = (reply: FastifyReply, value: string, expires: Date) => {
|
||||
reply.setCookie(REFRESH_COOKIE, value, {
|
||||
httpOnly: true,
|
||||
secure: config.AUTH_COOKIE_SECURE,
|
||||
sameSite: "lax",
|
||||
path: COOKIE_PATH,
|
||||
expires,
|
||||
...cookieDomain,
|
||||
})
|
||||
reply.setCookie(SESSION_HINT_COOKIE, "1", {
|
||||
httpOnly: false,
|
||||
secure: config.AUTH_COOKIE_SECURE,
|
||||
sameSite: "lax",
|
||||
path: "/",
|
||||
expires,
|
||||
...cookieDomain,
|
||||
})
|
||||
}
|
||||
|
||||
const clearRefreshCookie = (reply: FastifyReply) => {
|
||||
reply.clearCookie(REFRESH_COOKIE, { path: COOKIE_PATH, ...cookieDomain })
|
||||
reply.clearCookie(SESSION_HINT_COOKIE, { path: "/", ...cookieDomain })
|
||||
}
|
||||
|
||||
const session = (user: typeof users.$inferSelect) => ({
|
||||
accessToken: app.signAccessToken({ sub: user.id, role: user.role }),
|
||||
expiresIn: config.ACCESS_TOKEN_TTL_SECONDS,
|
||||
user: toUserDto(user),
|
||||
})
|
||||
|
||||
const sendVerificationEmail = async (userId: string, to: string, firstName: string) => {
|
||||
const t = await createOneTimeToken(db, emailVerificationTokens, userId, EMAIL_VERIFICATION_TTL_MS)
|
||||
const link = `${config.PRIVATE_APP_URL}/verify-email?token=${encodeURIComponent(t)}`
|
||||
await app.email.send({ to, template: "verify-email", data: { firstName, link } })
|
||||
}
|
||||
|
||||
// POST /auth/register — risposta sempre identica (202), anche se l'email esiste già
|
||||
app.post(
|
||||
"/register",
|
||||
{
|
||||
config: { rateLimit: EMAIL_LIMIT },
|
||||
schema: { body: registerBody, response: { 202: messageResponse } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = request.body
|
||||
const existing = await findUserByEmail(db, body.email)
|
||||
|
||||
if (existing) {
|
||||
// Stesso costo di una registrazione nuova (hash argon2), così i tempi di risposta non rivelano l'email
|
||||
await hashPassword(body.password)
|
||||
// Avvisa il titolare invece di rivelare al chiamante che l'email è registrata
|
||||
await app.email.send({
|
||||
to: existing.email,
|
||||
template: "register-existing",
|
||||
data: { firstName: existing.firstName, forgotPasswordUrl: `${config.PRIVATE_APP_URL}/forgot-password` },
|
||||
})
|
||||
return reply.status(202).send({ message: REGISTER_MESSAGE })
|
||||
}
|
||||
|
||||
const accountType =
|
||||
body.accountType ?? (body.companyName && body.vatNumber ? "company" : "customer")
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [user] = await tx
|
||||
.insert(users)
|
||||
.values({
|
||||
email: normalizeEmail(body.email),
|
||||
passwordHash: await hashPassword(body.password),
|
||||
role: accountType, // mai "admin": non ammesso dallo schema del body
|
||||
firstName: body.firstName,
|
||||
lastName: body.lastName,
|
||||
companyName: body.companyName ?? null,
|
||||
vatNumber: body.vatNumber ?? null,
|
||||
})
|
||||
.onConflictDoNothing()
|
||||
.returning()
|
||||
if (!user) return null // registrazione concorrente con la stessa email
|
||||
|
||||
if (accountType === "installer") {
|
||||
await tx.insert(installerProfiles).values({
|
||||
userId: user.id,
|
||||
businessName: body.companyName as string,
|
||||
vatNumber: body.vatNumber ?? null,
|
||||
phone: body.phone ?? null,
|
||||
status: "pending",
|
||||
})
|
||||
}
|
||||
|
||||
const consentBase = { userId: user.id, version: config.PRIVACY_POLICY_VERSION, source: "register", ip: request.ip }
|
||||
await tx.insert(consents).values([
|
||||
{ ...consentBase, type: "privacy" as const },
|
||||
...(body.acceptMarketing ? [{ ...consentBase, type: "marketing" as const }] : []),
|
||||
])
|
||||
return user
|
||||
})
|
||||
|
||||
if (created) {
|
||||
// Customer Medusa in background: se Medusa non risponde la registrazione riesce comunque
|
||||
// e il collegamento viene ritentato al login o alla prima operazione shop (ensureMedusaCustomer)
|
||||
app.syncInBackground("registrazione", created.id, () => ensureMedusaCustomer(app, created))
|
||||
await sendVerificationEmail(created.id, created.email, created.firstName)
|
||||
request.log.info({ userId: created.id, role: created.role }, "utente registrato")
|
||||
}
|
||||
return reply.status(202).send({ message: REGISTER_MESSAGE })
|
||||
},
|
||||
)
|
||||
|
||||
// POST /auth/login
|
||||
app.post(
|
||||
"/login",
|
||||
{
|
||||
config: { rateLimit: STRICT_LIMIT },
|
||||
schema: {
|
||||
body: z.object({ email, password: z.string().min(1).max(128) }),
|
||||
response: { 200: sessionResponse },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await checkCredentials(db, request.body.email, request.body.password)
|
||||
if (!user) throw new AppError(401, "INVALID_CREDENTIALS", "Email o password non corretti")
|
||||
|
||||
const refresh = await issueRefreshToken(db, user.id, config.REFRESH_TOKEN_TTL_DAYS, clientMeta(request))
|
||||
setRefreshCookie(reply, refresh.token, refresh.expiresAt)
|
||||
// Recupera un customer Medusa mancante e, con email verificata, collega eventuali ordini guest
|
||||
// (anche quelli fatti senza login dopo la registrazione). Idempotente, non blocca il login.
|
||||
// Richieste di preventivo anonime con la stessa email (solo con email verificata)
|
||||
await linkAnonymousQuoteRequests(db, user)
|
||||
app.syncInBackground("login", user.id, () =>
|
||||
user.emailVerifiedAt ? claimGuestOrders(app, user) : ensureMedusaCustomer(app, user),
|
||||
)
|
||||
// Installatori: riallinea il customer group Medusa (listino riservato) allo stato del profilo.
|
||||
// Recupera una sincronizzazione fallita all'approvazione; idempotente.
|
||||
if (user.role === "installer") {
|
||||
app.syncInBackground("gruppo installatori", user.id, () => syncInstallerGroup(app, user))
|
||||
}
|
||||
// Carrello guest aperto (cookie cart_id): associato subito al customer dell'utente, così il
|
||||
// frontend lo ritrova già riprezzato (es. listino installatori). Se Medusa non risponde il login
|
||||
// riesce comunque e l'associazione avviene alla prima operazione sul carrello.
|
||||
const cartId = readCartCookie(request)
|
||||
if (cartId) {
|
||||
await trySync(request.log, "carrello al login", user.id, async () =>
|
||||
app.shop.resolveCart(cartId, await app.shop.viewerFor(user)),
|
||||
)
|
||||
}
|
||||
return session(user)
|
||||
},
|
||||
)
|
||||
|
||||
// POST /auth/refresh — rotazione del refresh token (cookie), nuovo access token
|
||||
app.post(
|
||||
"/refresh",
|
||||
{ config: { rateLimit: { max: 30, timeWindow: "1 minute" } }, schema: { response: { 200: sessionResponse } } },
|
||||
async (request, reply) => {
|
||||
const presented = request.cookies[REFRESH_COOKIE]
|
||||
if (!presented) throw new AppError(401, "UNAUTHORIZED", "Sessione non presente")
|
||||
|
||||
const result = await rotateRefreshToken(db, presented, config.REFRESH_TOKEN_TTL_DAYS, clientMeta(request))
|
||||
if (!result.ok) {
|
||||
if (result.reason === "reused") {
|
||||
request.log.warn({ ip: request.ip }, "riuso di refresh token revocato: family revocata")
|
||||
}
|
||||
clearRefreshCookie(reply)
|
||||
throw new AppError(401, "UNAUTHORIZED", "Sessione scaduta, accedi di nuovo")
|
||||
}
|
||||
|
||||
setRefreshCookie(reply, result.token, result.expiresAt)
|
||||
return session(result.user)
|
||||
},
|
||||
)
|
||||
|
||||
// POST /auth/logout — revoca la sessione corrente (family del cookie), sempre 204
|
||||
app.post("/logout", async (request, reply) => {
|
||||
const presented = request.cookies[REFRESH_COOKIE]
|
||||
if (presented) await revokeByToken(db, presented)
|
||||
clearRefreshCookie(reply)
|
||||
// Il carrello resta dell'account in Medusa: sul dispositivo si riparte con un carrello nuovo
|
||||
clearCartCookie(reply, config)
|
||||
return reply.status(204).send()
|
||||
})
|
||||
|
||||
// POST /auth/forgot-password — risposta sempre identica
|
||||
app.post(
|
||||
"/forgot-password",
|
||||
{
|
||||
config: { rateLimit: EMAIL_LIMIT },
|
||||
schema: { body: z.object({ email }), response: { 202: messageResponse } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await findUserByEmail(db, request.body.email)
|
||||
if (user && !user.disabledAt) {
|
||||
const t = await createOneTimeToken(db, passwordResetTokens, user.id, PASSWORD_RESET_TTL_MS)
|
||||
const link = `${config.PRIVATE_APP_URL}/reset-password?token=${encodeURIComponent(t)}`
|
||||
await app.email.send({ to: user.email, template: "reset-password", data: { firstName: user.firstName, link } })
|
||||
}
|
||||
return reply.status(202).send({ message: FORGOT_MESSAGE })
|
||||
},
|
||||
)
|
||||
|
||||
// POST /auth/reset-password — nuova password, chiude tutte le sessioni
|
||||
app.post(
|
||||
"/reset-password",
|
||||
{
|
||||
config: { rateLimit: STRICT_LIMIT },
|
||||
schema: { body: z.object({ token, password }), response: { 200: messageResponse } },
|
||||
},
|
||||
async (request) => {
|
||||
const userId = await consumeOneTimeToken(db, passwordResetTokens, request.body.token)
|
||||
if (!userId) throw new AppError(400, "INVALID_TOKEN", "Link non valido o scaduto")
|
||||
|
||||
const passwordHash = await hashPassword(request.body.password)
|
||||
const user = await findUserById(db, userId)
|
||||
const verifiesEmail = user !== undefined && !user.emailVerifiedAt
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({
|
||||
passwordHash,
|
||||
// Aprire il link ricevuto per email dimostra il possesso dell'indirizzo
|
||||
...(verifiesEmail ? { emailVerifiedAt: new Date() } : {}),
|
||||
})
|
||||
.where(eq(users.id, userId))
|
||||
.returning()
|
||||
await revokeAllUserTokens(db, userId)
|
||||
if (verifiesEmail && updated) {
|
||||
await linkAnonymousQuoteRequests(db, updated)
|
||||
app.syncInBackground("claim ordini guest", updated.id, () => claimGuestOrders(app, updated))
|
||||
}
|
||||
return { message: "Password aggiornata. Accedi con la nuova password." }
|
||||
},
|
||||
)
|
||||
|
||||
// POST /auth/verify-email
|
||||
app.post(
|
||||
"/verify-email",
|
||||
{
|
||||
config: { rateLimit: STRICT_LIMIT },
|
||||
schema: { body: z.object({ token }), response: { 200: messageResponse } },
|
||||
},
|
||||
async (request) => {
|
||||
const userId = await consumeOneTimeToken(db, emailVerificationTokens, request.body.token)
|
||||
if (!userId) throw new AppError(400, "INVALID_TOKEN", "Link non valido o scaduto")
|
||||
|
||||
const [verified] = await db
|
||||
.update(users)
|
||||
.set({ emailVerifiedAt: new Date() })
|
||||
.where(and(eq(users.id, userId), isNull(users.emailVerifiedAt)))
|
||||
.returning()
|
||||
// Email verificata: si possono collegare gli ordini guest fatti con questo indirizzo
|
||||
if (verified) {
|
||||
// Email verificata: collega richieste di preventivo anonime e ordini guest con questo indirizzo
|
||||
await linkAnonymousQuoteRequests(db, verified)
|
||||
app.syncInBackground("claim ordini guest", verified.id, () => claimGuestOrders(app, verified))
|
||||
}
|
||||
return { message: "Email confermata." }
|
||||
},
|
||||
)
|
||||
|
||||
// POST /auth/verify-email/resend — nuovo link di verifica per l'utente autenticato
|
||||
app.post(
|
||||
"/verify-email/resend",
|
||||
{
|
||||
onRequest: [app.authenticate],
|
||||
config: { rateLimit: EMAIL_LIMIT },
|
||||
schema: { response: { 202: messageResponse } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await findUserById(db, request.user.sub)
|
||||
if (user && !user.emailVerifiedAt && !user.disabledAt) {
|
||||
await sendVerificationEmail(user.id, user.email, user.firstName)
|
||||
}
|
||||
return reply.status(202).send({ message: "Se l'email non è ancora confermata riceverai un nuovo link." })
|
||||
},
|
||||
)
|
||||
|
||||
/**
|
||||
* POST /auth/change-password — cambio password dall'area riservata (richiede quella attuale).
|
||||
* Chiude **tutte** le sessioni dell'utente e ne apre una nuova su questo dispositivo (nuovo cookie e nuovo
|
||||
* access token nella risposta): gli altri dispositivi devono rifare il login. Sta sotto /auth perché il
|
||||
* cookie di refresh (path /auth) deve arrivare qui per essere sostituito.
|
||||
*/
|
||||
app.post(
|
||||
"/change-password",
|
||||
{
|
||||
onRequest: [app.authenticate],
|
||||
config: { rateLimit: STRICT_LIMIT },
|
||||
schema: {
|
||||
body: z.object({ currentPassword: z.string().min(1).max(128), newPassword: password }),
|
||||
response: { 200: sessionResponse },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const user = await findUserById(db, request.user.sub)
|
||||
if (!user || user.disabledAt) throw new AppError(401, "UNAUTHORIZED", "Account non disponibile")
|
||||
if (!(await verifyPassword(user.passwordHash, request.body.currentPassword))) {
|
||||
throw new AppError(400, "INVALID_CURRENT_PASSWORD", "La password attuale non è corretta", [
|
||||
{ path: "body.currentPassword", message: "La password attuale non è corretta" },
|
||||
])
|
||||
}
|
||||
if (request.body.currentPassword === request.body.newPassword) {
|
||||
throw new AppError(400, "VALIDATION_ERROR", "La nuova password deve essere diversa da quella attuale", [
|
||||
{ path: "body.newPassword", message: "Deve essere diversa da quella attuale" },
|
||||
])
|
||||
}
|
||||
const [updated] = await db
|
||||
.update(users)
|
||||
.set({ passwordHash: await hashPassword(request.body.newPassword) })
|
||||
.where(eq(users.id, user.id))
|
||||
.returning()
|
||||
await revokeAllUserTokens(db, user.id)
|
||||
const refresh = await issueRefreshToken(db, user.id, config.REFRESH_TOKEN_TTL_DAYS, clientMeta(request))
|
||||
setRefreshCookie(reply, refresh.token, refresh.expiresAt)
|
||||
request.log.info({ userId: user.id }, "password cambiata: sessioni chiuse")
|
||||
return session(updated!)
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default authRoutes
|
||||
@@ -0,0 +1,144 @@
|
||||
import { and, asc, desc, eq, isNotNull, lte, sql } from "drizzle-orm"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { blogCategories, blogPosts } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { toCategoryDto, toDetailDto, toSummaryDto } from "../../services/blog.js"
|
||||
|
||||
export const categoryDto = z.object({
|
||||
id: z.uuid(),
|
||||
slug: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const postSummaryDto = z.object({
|
||||
id: z.uuid(),
|
||||
slug: z.string(),
|
||||
title: z.string(),
|
||||
excerpt: z.string().nullable(),
|
||||
coverUrl: z.string().nullable(),
|
||||
category: categoryDto.nullable(),
|
||||
publishedAt: z.string().nullable(),
|
||||
})
|
||||
|
||||
export const postDetailDto = postSummaryDto.extend({
|
||||
contentMd: z.string(),
|
||||
contentHtml: z.string(),
|
||||
seoTitle: z.string().nullable(),
|
||||
seoDescription: z.string().nullable(),
|
||||
updatedAt: z.string(),
|
||||
})
|
||||
|
||||
/** Post visibili al pubblico: pubblicati e con data di pubblicazione già passata (le future sono programmate). */
|
||||
export const isPublic = and(
|
||||
eq(blogPosts.status, "published"),
|
||||
isNotNull(blogPosts.publishedAt),
|
||||
lte(blogPosts.publishedAt, sql`now()`),
|
||||
)
|
||||
|
||||
/** Condizione full-text: stemming italiano + unaccent (stessa configurazione della colonna generata). */
|
||||
export const tsQuery = (q: string) => sql`websearch_to_tsquery('italian', unaccent(${q}))`
|
||||
|
||||
const blogRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const coverUrl = (path: string) => app.storage.publicUrl(path)
|
||||
|
||||
// GET /blog/posts — post pubblicati; ?q= ricerca full-text ordinata per rilevanza
|
||||
app.get(
|
||||
"/blog/posts",
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
category: z.string().trim().min(1).max(100).optional(),
|
||||
q: z.string().trim().min(1).max(200).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(50).default(10),
|
||||
offset: z.coerce.number().int().min(0).max(10_000).default(0),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({ items: z.array(postSummaryDto), total: z.number().int(), limit: z.number().int(), offset: z.number().int() }),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { category, q, limit, offset } = request.query
|
||||
const query = q ? tsQuery(q) : undefined
|
||||
const where = and(
|
||||
isPublic,
|
||||
category ? eq(blogCategories.slug, category) : undefined,
|
||||
query ? sql`${blogPosts.searchVector} @@ ${query}` : undefined,
|
||||
)
|
||||
const order = query
|
||||
? [desc(sql`ts_rank(${blogPosts.searchVector}, ${query})`), desc(blogPosts.publishedAt)]
|
||||
: [desc(blogPosts.publishedAt), asc(blogPosts.id)]
|
||||
|
||||
const [rows, [count]] = await Promise.all([
|
||||
app.db
|
||||
.select({ post: blogPosts, category: blogCategories })
|
||||
.from(blogPosts)
|
||||
.leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id))
|
||||
.where(where)
|
||||
.orderBy(...order)
|
||||
.limit(limit)
|
||||
.offset(offset),
|
||||
app.db
|
||||
.select({ n: sql<number>`count(*)::int` })
|
||||
.from(blogPosts)
|
||||
.leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id))
|
||||
.where(where),
|
||||
])
|
||||
return { items: rows.map((r) => toSummaryDto(r.post, r.category, coverUrl)), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
// GET /blog/posts/:slug — dettaglio (markdown + HTML sanitizzato); bozze e programmati → 404
|
||||
app.get(
|
||||
"/blog/posts/:slug",
|
||||
{ schema: { params: z.object({ slug: z.string().min(1).max(100) }), response: { 200: postDetailDto } } },
|
||||
async (request) => {
|
||||
const [row] = await app.db
|
||||
.select({ post: blogPosts, category: blogCategories })
|
||||
.from(blogPosts)
|
||||
.leftJoin(blogCategories, eq(blogPosts.categoryId, blogCategories.id))
|
||||
.where(and(isPublic, eq(blogPosts.slug, request.params.slug)))
|
||||
if (!row) throw new AppError(404, "POST_NOT_FOUND", "Articolo non trovato")
|
||||
return toDetailDto(row.post, row.category, coverUrl)
|
||||
},
|
||||
)
|
||||
|
||||
app.get("/blog/categories", { schema: { response: { 200: z.array(categoryDto) } } }, async () => {
|
||||
const rows = await app.db.select().from(blogCategories).orderBy(asc(blogCategories.name))
|
||||
return rows.map(toCategoryDto)
|
||||
})
|
||||
|
||||
// GET /sitemap-data — slug e date per la sitemap generata dal frontend SSR
|
||||
app.get(
|
||||
"/sitemap-data",
|
||||
{
|
||||
schema: {
|
||||
response: {
|
||||
200: z.object({
|
||||
posts: z.array(z.object({ slug: z.string(), publishedAt: z.string(), updatedAt: z.string() })),
|
||||
categories: z.array(z.object({ slug: z.string(), updatedAt: z.string() })),
|
||||
}),
|
||||
},
|
||||
},
|
||||
},
|
||||
async (_request, reply) => {
|
||||
const [posts, categories] = await Promise.all([
|
||||
app.db
|
||||
.select({ slug: blogPosts.slug, publishedAt: blogPosts.publishedAt, updatedAt: blogPosts.updatedAt })
|
||||
.from(blogPosts)
|
||||
.where(isPublic)
|
||||
.orderBy(desc(blogPosts.publishedAt)),
|
||||
app.db.select({ slug: blogCategories.slug, updatedAt: blogCategories.updatedAt }).from(blogCategories),
|
||||
])
|
||||
reply.header("cache-control", "public, max-age=300")
|
||||
return {
|
||||
posts: posts.map((p) => ({ slug: p.slug, publishedAt: p.publishedAt!.toISOString(), updatedAt: p.updatedAt.toISOString() })),
|
||||
categories: categories.map((c) => ({ slug: c.slug, updatedAt: c.updatedAt.toISOString() })),
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default blogRoutes
|
||||
@@ -0,0 +1,156 @@
|
||||
import type { ConfiguratorAnswers, ConfiguratorDefinition, ConfiguratorResult } from "@iti/shared"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { definition, DEFINITION_VERSION } from "../../configurator/definition.js"
|
||||
import { evaluate, validateAnswers } from "../../configurator/engine.js"
|
||||
import { consents, quoteRequests } from "../../db/schema/index.js"
|
||||
import { formatCents } from "../../emails/html.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { findUserById, normalizeEmail } from "../../services/auth.js"
|
||||
import { adminRecipients } from "../../services/notifications.js"
|
||||
|
||||
const resultSchema = z.object({
|
||||
rulesVersion: z.string(),
|
||||
path: z.enum(["acqua", "fotovoltaico", "pompa_calore", "azienda"]),
|
||||
solution: z.object({ code: z.string(), title: z.string(), description: z.string() }),
|
||||
size: z.object({ value: z.number(), unit: z.string(), label: z.string() }).nullable(),
|
||||
products: z.array(z.object({ handle: z.string(), quantity: z.number().int(), note: z.string().nullable() })),
|
||||
estimates: z.object({
|
||||
investmentCents: z.number().int().nullable(),
|
||||
annualSavingsCents: z.number().int().nullable(),
|
||||
paybackYears: z.number().nullable(),
|
||||
}),
|
||||
details: z.array(z.object({ label: z.string(), value: z.string() })),
|
||||
warnings: z.array(z.string()),
|
||||
incentivesNote: z.string(),
|
||||
disclaimer: z.string(),
|
||||
}) satisfies z.ZodType<ConfiguratorResult>
|
||||
|
||||
const answersBody = z.record(z.string().max(50), z.unknown())
|
||||
|
||||
/** Centesimi → "1.500 €" (euro interi, separatore delle migliaia sempre presente). */
|
||||
const euro = (cents: number) => formatCents(Math.round(cents / 100) * 100).replace(/,00(?=\s?€)/, "")
|
||||
|
||||
/** Righe di riepilogo (email): soluzione, taglia, stime. */
|
||||
function summaryOf(result: ConfiguratorResult) {
|
||||
const rows = [{ label: "Soluzione", value: result.solution.title }]
|
||||
if (result.size) rows.push({ label: "Taglia", value: result.size.label })
|
||||
const e = result.estimates
|
||||
if (e.investmentCents !== null) rows.push({ label: "Investimento indicativo", value: euro(e.investmentCents) })
|
||||
if (e.annualSavingsCents !== null) rows.push({ label: "Risparmio annuo stimato", value: euro(e.annualSavingsCents) })
|
||||
if (e.paybackYears !== null) {
|
||||
rows.push({ label: "Rientro stimato", value: `${new Intl.NumberFormat("it-IT").format(e.paybackYears)} anni` })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Configuratore a regole (nessuna AI). Definizione in src/configurator/definition.ts,
|
||||
* regole in src/configurator/rules.ts, calcolo in src/configurator/engine.ts.
|
||||
*/
|
||||
const configuratorRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, db } = app
|
||||
|
||||
/** Valida le risposte (400 VALIDATION_ERROR con dettaglio per domanda) e calcola. */
|
||||
const run = (raw: Record<string, unknown>): { answers: ConfiguratorAnswers; result: ConfiguratorResult } => {
|
||||
const validated = validateAnswers(definition, raw)
|
||||
if (!validated.ok) throw new AppError(400, "VALIDATION_ERROR", "Risposte non valide o incoerenti", validated.errors)
|
||||
return { answers: validated.answers, result: evaluate(validated.answers) }
|
||||
}
|
||||
|
||||
app.get(
|
||||
"/configurator/definition",
|
||||
{ schema: { response: { 200: z.custom<ConfiguratorDefinition>() } } },
|
||||
async (_request, reply) => {
|
||||
reply.header("cache-control", "public, max-age=300")
|
||||
return definition
|
||||
},
|
||||
)
|
||||
|
||||
// POST /configurator/evaluate — calcola senza salvare (anteprima del risultato nel wizard)
|
||||
app.post(
|
||||
"/configurator/evaluate",
|
||||
{
|
||||
config: { rateLimit: { max: 60, timeWindow: "1 minute" } },
|
||||
schema: { body: z.object({ answers: answersBody }), response: { 200: resultSchema } },
|
||||
},
|
||||
async (request) => run(request.body.answers).result,
|
||||
)
|
||||
|
||||
// POST /configurator/submit — salva la richiesta di preventivo, avvisa l'admin e conferma all'utente
|
||||
app.post(
|
||||
"/configurator/submit",
|
||||
{
|
||||
onRequest: [app.optionalAuthenticate],
|
||||
config: { rateLimit: { max: 5, timeWindow: "15 minutes" } },
|
||||
schema: {
|
||||
body: z.object({
|
||||
answers: answersBody,
|
||||
contact: z.object({
|
||||
name: z.string().trim().min(1, "Nome obbligatorio").max(120),
|
||||
email: z.string().trim().toLowerCase().pipe(z.email("Email non valida")),
|
||||
phone: z.string().trim().min(5).max(30).optional(),
|
||||
}),
|
||||
message: z.string().trim().max(2000).optional(),
|
||||
acceptPrivacy: z.literal(true, { error: "Il consenso al trattamento dei dati è obbligatorio" }),
|
||||
acceptMarketing: z.boolean().optional(),
|
||||
}),
|
||||
response: { 201: z.object({ id: z.uuid(), result: resultSchema }) },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const { contact, message, acceptMarketing } = request.body
|
||||
const { answers, result } = run(request.body.answers)
|
||||
const user = request.user ? await findUserById(db, request.user.sub) : undefined
|
||||
const userId = user && !user.disabledAt ? user.id : null
|
||||
const email = normalizeEmail(contact.email)
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(quoteRequests)
|
||||
.values({
|
||||
userId,
|
||||
email,
|
||||
name: contact.name,
|
||||
phone: contact.phone ?? null,
|
||||
province: typeof answers.provincia === "string" ? answers.provincia : null,
|
||||
configuratorInput: { definitionVersion: DEFINITION_VERSION, answers, ...(message ? { message } : {}) },
|
||||
configuratorOutput: result,
|
||||
})
|
||||
.returning({ id: quoteRequests.id })
|
||||
const consentBase = { userId, email, version: config.PRIVACY_POLICY_VERSION, source: "configurator", ip: request.ip }
|
||||
await tx.insert(consents).values([
|
||||
{ ...consentBase, type: "privacy" as const },
|
||||
...(acceptMarketing ? [{ ...consentBase, type: "marketing" as const }] : []),
|
||||
])
|
||||
return row!
|
||||
})
|
||||
|
||||
const summary = summaryOf(result)
|
||||
for (const to of await adminRecipients(db, config)) {
|
||||
await app.email.send({
|
||||
to,
|
||||
template: "quote-request-admin",
|
||||
data: {
|
||||
name: contact.name,
|
||||
email,
|
||||
phone: contact.phone ?? null,
|
||||
province: typeof answers.provincia === "string" ? answers.provincia : null,
|
||||
summary: [...summary, ...result.details, ...(message ? [{ label: "Messaggio", value: message }] : [])],
|
||||
adminUrl: `${config.PRIVATE_APP_URL}/admin/richieste/${created.id}`,
|
||||
},
|
||||
})
|
||||
}
|
||||
await app.email.send({
|
||||
to: email,
|
||||
template: "quote-request-received",
|
||||
data: { name: contact.name, solutionTitle: result.solution.title, summary, disclaimer: result.disclaimer },
|
||||
})
|
||||
|
||||
request.log.info({ quoteRequestId: created.id, path: result.path, userId }, "richiesta di preventivo dal configuratore")
|
||||
return reply.status(201).send({ id: created.id, result })
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default configuratorRoutes
|
||||
@@ -0,0 +1,98 @@
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { contactRequests } from "../../db/schema/index.js"
|
||||
import { findUserById, normalizeEmail } from "../../services/auth.js"
|
||||
import { recordConsents } from "../../services/consents.js"
|
||||
import { createFormToken, FORM_MIN_SECONDS, formGuardFields, passesFormGuard } from "../../services/form-guard.js"
|
||||
import { notifyStaff } from "../../services/notifications.js"
|
||||
|
||||
/** Campi comuni dei form pubblici con dati personali (contatti, showroom). */
|
||||
export const personalDataFields = {
|
||||
name: z.string().trim().min(1, "Nome obbligatorio").max(120),
|
||||
email: z.string().trim().toLowerCase().pipe(z.email("Email non valida")),
|
||||
phone: z.string().trim().min(5).max(30).optional(),
|
||||
acceptPrivacy: z.literal(true, { error: "Il consenso al trattamento dei dati è obbligatorio" }),
|
||||
acceptMarketing: z.boolean().optional(),
|
||||
...formGuardFields,
|
||||
}
|
||||
|
||||
export const messageResponse = z.object({ message: z.string() })
|
||||
|
||||
const CONTACT_RECEIVED = "Messaggio inviato: ti risponderemo al più presto"
|
||||
|
||||
/** Form contatti e token anti-spam dei form pubblici (vedi services/form-guard.ts). */
|
||||
const contactRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, db } = app
|
||||
|
||||
// GET /forms/token — da chiedere all'apertura del form; il form va inviato dopo FORM_MIN_SECONDS
|
||||
app.get(
|
||||
"/forms/token",
|
||||
{
|
||||
config: { rateLimit: { max: 60, timeWindow: "1 minute" } },
|
||||
schema: { response: { 200: z.object({ token: z.string(), minSeconds: z.number().int(), expiresAt: z.string() }) } },
|
||||
},
|
||||
async (_request, reply) => {
|
||||
reply.header("cache-control", "no-store")
|
||||
const { token, expiresAt } = createFormToken(config.COOKIE_SECRET)
|
||||
return { token, minSeconds: FORM_MIN_SECONDS, expiresAt: expiresAt.toISOString() }
|
||||
},
|
||||
)
|
||||
|
||||
// POST /contact — salva la richiesta e avvisa lo staff. Spam (honeypot, invio troppo rapido) → stessa risposta, nulla salvato
|
||||
app.post(
|
||||
"/contact",
|
||||
{
|
||||
onRequest: [app.optionalAuthenticate],
|
||||
config: { rateLimit: { max: 5, timeWindow: "15 minutes" } },
|
||||
schema: {
|
||||
body: z.object({
|
||||
...personalDataFields,
|
||||
subject: z.string().trim().min(1).max(200).optional(),
|
||||
message: z.string().trim().min(10, "Messaggio troppo breve").max(5000),
|
||||
}),
|
||||
response: { 200: messageResponse },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const body = request.body
|
||||
if (!passesFormGuard(request, config.COOKIE_SECRET, body, "contact")) return { message: CONTACT_RECEIVED }
|
||||
|
||||
const user = request.user ? await findUserById(db, request.user.sub) : undefined
|
||||
const userId = user && !user.disabledAt ? user.id : null
|
||||
const email = normalizeEmail(body.email)
|
||||
|
||||
const created = await db.transaction(async (tx) => {
|
||||
const [row] = await tx
|
||||
.insert(contactRequests)
|
||||
.values({ userId, name: body.name, email, phone: body.phone ?? null, subject: body.subject ?? null, message: body.message })
|
||||
.returning({ id: contactRequests.id })
|
||||
await recordConsents(tx, {
|
||||
userId,
|
||||
email,
|
||||
version: config.PRIVACY_POLICY_VERSION,
|
||||
source: "contact",
|
||||
ip: request.ip,
|
||||
marketing: body.acceptMarketing === true,
|
||||
})
|
||||
return row!
|
||||
})
|
||||
|
||||
await notifyStaff(app, {
|
||||
title: `Nuovo messaggio dal sito - ${body.name}`,
|
||||
intro: "È arrivato un nuovo messaggio dal form contatti.",
|
||||
rows: [
|
||||
{ label: "Nome", value: body.name },
|
||||
{ label: "Email", value: email },
|
||||
{ label: "Telefono", value: body.phone ?? "-" },
|
||||
{ label: "Oggetto", value: body.subject ?? "-" },
|
||||
{ label: "Messaggio", value: body.message },
|
||||
],
|
||||
url: `${config.PRIVATE_APP_URL}/admin/contatti/${created.id}`,
|
||||
})
|
||||
request.log.info({ contactRequestId: created.id, userId }, "nuova richiesta di contatto")
|
||||
return { message: CONTACT_RECEIVED }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default contactRoutes
|
||||
@@ -0,0 +1,87 @@
|
||||
import { and, desc, eq, inArray, sql } from "drizzle-orm"
|
||||
import type { FastifyRequest } from "fastify"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { documents } from "../../db/schema/index.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { findUserById } from "../../services/auth.js"
|
||||
import { downloadFilename, sendDocumentFile, toDocumentDto, visibleLevels } from "../../services/documents.js"
|
||||
|
||||
export const documentDto = z.object({
|
||||
id: z.uuid(),
|
||||
title: z.string(),
|
||||
category: z.string().nullable(),
|
||||
mimeType: z.string(),
|
||||
sizeBytes: z.number().int(),
|
||||
visibility: z.enum(["public", "customer", "installer", "admin"]),
|
||||
productRef: z.string().nullable(),
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
export const paginatedDocuments = z.object({
|
||||
items: z.array(documentDto),
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
})
|
||||
|
||||
/**
|
||||
* Documenti scaricabili (schede tecniche, manuali, certificazioni).
|
||||
* Autenticazione facoltativa: l'elenco e il download dipendono dal ruolo (vedi visibleLevels).
|
||||
*/
|
||||
const documentRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const levelsFor = async (request: FastifyRequest) =>
|
||||
visibleLevels(app.db, request.user ? await findUserById(app.db, request.user.sub) : undefined)
|
||||
|
||||
// GET /documents — documenti visibili al ruolo (solo pubblici se anonimo)
|
||||
app.get(
|
||||
"/documents",
|
||||
{
|
||||
onRequest: [app.optionalAuthenticate],
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
category: z.string().trim().min(1).max(100).optional(),
|
||||
productRef: z.string().trim().min(1).max(200).optional(),
|
||||
limit: z.coerce.number().int().min(1).max(100).default(50),
|
||||
offset: z.coerce.number().int().min(0).default(0),
|
||||
}),
|
||||
response: { 200: paginatedDocuments },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const { category, productRef, limit, offset } = request.query
|
||||
const where = and(
|
||||
inArray(documents.visibility, await levelsFor(request)),
|
||||
category ? eq(documents.category, category) : undefined,
|
||||
productRef ? eq(documents.productRef, productRef) : undefined,
|
||||
)
|
||||
const [rows, [count]] = await Promise.all([
|
||||
app.db.select().from(documents).where(where).orderBy(desc(documents.createdAt)).limit(limit).offset(offset),
|
||||
app.db.select({ n: sql<number>`count(*)::int` }).from(documents).where(where),
|
||||
])
|
||||
return { items: rows.map(toDocumentDto), total: count?.n ?? 0, limit, offset }
|
||||
},
|
||||
)
|
||||
|
||||
// GET /documents/:id/download — stream del file (area private) se il ruolo lo consente
|
||||
app.get(
|
||||
"/documents/:id/download",
|
||||
{
|
||||
onRequest: [app.optionalAuthenticate],
|
||||
schema: { params: z.object({ id: z.uuid() }) },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const [doc] = await app.db.select().from(documents).where(eq(documents.id, request.params.id))
|
||||
if (!doc) throw new AppError(404, "DOCUMENT_NOT_FOUND", "Documento non trovato")
|
||||
|
||||
if (!(await levelsFor(request)).includes(doc.visibility)) {
|
||||
if (!request.user) throw new AppError(401, "UNAUTHORIZED", "Accedi per scaricare questo documento")
|
||||
throw new AppError(403, "FORBIDDEN", "Non hai accesso a questo documento")
|
||||
}
|
||||
|
||||
return sendDocumentFile(request, reply, app.storage, doc, downloadFilename(doc.title, doc.filePath), doc.visibility === "public")
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default documentRoutes
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
|
||||
const healthResponse = z.object({
|
||||
status: z.literal("ok"),
|
||||
uptime: z.number(),
|
||||
timestamp: z.iso.datetime(),
|
||||
})
|
||||
|
||||
/** GET /health — liveness per Docker/Caddy. Escluso dal rate limit. */
|
||||
const healthRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
app.get(
|
||||
"/health",
|
||||
{
|
||||
config: { rateLimit: false },
|
||||
// Healthcheck Docker ogni 15 s: niente righe "incoming request"/"request completed" nei log (errori sì)
|
||||
logLevel: "warn",
|
||||
schema: { response: { 200: healthResponse } },
|
||||
},
|
||||
async () => ({
|
||||
status: "ok" as const,
|
||||
uptime: Math.round(process.uptime()),
|
||||
timestamp: new Date().toISOString(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
export default healthRoutes
|
||||
@@ -0,0 +1,391 @@
|
||||
import type {
|
||||
Paginated,
|
||||
ShopCartDto,
|
||||
ShopCategoryDto,
|
||||
ShopInvoiceDto,
|
||||
ShopOrderDto,
|
||||
ShopPaymentDto,
|
||||
ShopProductDetailDto,
|
||||
ShopProductSummaryDto,
|
||||
ShopShippingOptionDto,
|
||||
} from "@iti/shared"
|
||||
import { FISCAL_CODE_PATTERN, isValidItalianVat, normalizeVatNumber, SDI_CODE_PATTERN } from "@iti/shared"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { findUserById, normalizeEmail } from "../../services/auth.js"
|
||||
import { recordConsentsOnce } from "../../services/consents.js"
|
||||
import { clearCartCookie, readCartCookie, setCartCookie } from "../../services/cart-cookie.js"
|
||||
import { CART_COOKIE, type ShopViewer, toCartDto } from "../../services/shop.js"
|
||||
|
||||
// --- Schemi delle risposte (stessa forma dei DTO in @iti/shared) ---
|
||||
|
||||
const priceType = z.enum(["standard", "installer"])
|
||||
const categoryRef = z.object({ handle: z.string(), name: z.string() })
|
||||
|
||||
const productSummary = z.object({
|
||||
handle: z.string(),
|
||||
title: z.string(),
|
||||
subtitle: z.string().nullable(),
|
||||
thumbnail: z.string().nullable(),
|
||||
fromPrice: z.number().int().nullable(),
|
||||
fromOriginalPrice: z.number().int().nullable(),
|
||||
priceType,
|
||||
categories: z.array(categoryRef),
|
||||
}) satisfies z.ZodType<ShopProductSummaryDto>
|
||||
|
||||
const productDetail = productSummary.extend({
|
||||
description: z.string().nullable(),
|
||||
images: z.array(z.string()),
|
||||
variants: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
title: z.string(),
|
||||
sku: z.string().nullable(),
|
||||
price: z.number().int().nullable(),
|
||||
originalPrice: z.number().int().nullable(),
|
||||
inStock: z.boolean(),
|
||||
options: z.array(z.object({ name: z.string(), value: z.string() })),
|
||||
}),
|
||||
),
|
||||
}) satisfies z.ZodType<ShopProductDetailDto>
|
||||
|
||||
const category = z.object({
|
||||
handle: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
parentHandle: z.string().nullable(),
|
||||
}) satisfies z.ZodType<ShopCategoryDto>
|
||||
|
||||
const address = z.object({
|
||||
firstName: z.string(),
|
||||
lastName: z.string(),
|
||||
company: z.string().nullable(),
|
||||
address1: z.string(),
|
||||
address2: z.string().nullable(),
|
||||
city: z.string(),
|
||||
postalCode: z.string(),
|
||||
province: z.string().nullable(),
|
||||
countryCode: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
})
|
||||
|
||||
const invoice = z.object({
|
||||
companyName: z.string(),
|
||||
vatNumber: z.string(),
|
||||
fiscalCode: z.string().nullable(),
|
||||
sdiCode: z.string().nullable(),
|
||||
pec: z.string().nullable(),
|
||||
}) satisfies z.ZodType<ShopInvoiceDto>
|
||||
|
||||
const cart = z.object({
|
||||
email: z.string().nullable(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
id: z.string(),
|
||||
variantId: z.string(),
|
||||
productHandle: z.string().nullable(),
|
||||
productTitle: z.string(),
|
||||
variantTitle: z.string().nullable(),
|
||||
sku: z.string().nullable(),
|
||||
thumbnail: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number().int(),
|
||||
total: z.number().int(),
|
||||
}),
|
||||
),
|
||||
itemCount: z.number().int(),
|
||||
shippingAddress: address.nullable(),
|
||||
billingAddress: address.nullable(),
|
||||
invoice: invoice.nullable(),
|
||||
shippingMethod: z.object({ optionId: z.string(), name: z.string(), amount: z.number().int() }).nullable(),
|
||||
totals: z.object({
|
||||
items: z.number().int(),
|
||||
shipping: z.number().int(),
|
||||
discount: z.number().int(),
|
||||
tax: z.number().int(),
|
||||
total: z.number().int(),
|
||||
}),
|
||||
readyForPayment: z.boolean(),
|
||||
}) satisfies z.ZodType<ShopCartDto>
|
||||
|
||||
const shippingOption = z.object({ id: z.string(), name: z.string(), amount: z.number().int() }) satisfies z.ZodType<ShopShippingOptionDto>
|
||||
|
||||
const payment = z.object({
|
||||
provider: z.literal("stripe"),
|
||||
clientSecret: z.string(),
|
||||
amount: z.number().int(),
|
||||
}) satisfies z.ZodType<ShopPaymentDto>
|
||||
|
||||
const order = z.object({
|
||||
number: z.number().int(),
|
||||
email: z.string(),
|
||||
createdAt: z.string(),
|
||||
items: z.array(
|
||||
z.object({
|
||||
productTitle: z.string(),
|
||||
variantTitle: z.string().nullable(),
|
||||
quantity: z.number().int(),
|
||||
unitPrice: z.number().int(),
|
||||
total: z.number().int(),
|
||||
}),
|
||||
),
|
||||
totals: z.object({ items: z.number().int(), shipping: z.number().int(), tax: z.number().int(), total: z.number().int() }),
|
||||
}) satisfies z.ZodType<ShopOrderDto>
|
||||
|
||||
// --- Schemi di input ---
|
||||
|
||||
const optionalText = (max: number) => z.string().trim().max(max).optional().transform((v) => v || undefined)
|
||||
|
||||
const addressInput = z.object({
|
||||
firstName: z.string().trim().min(1).max(100),
|
||||
lastName: z.string().trim().min(1).max(100),
|
||||
company: optionalText(200),
|
||||
address1: z.string().trim().min(1).max(200),
|
||||
address2: optionalText(200),
|
||||
city: z.string().trim().min(1).max(100),
|
||||
postalCode: z.string().trim().regex(/^\d{5}$/, "CAP non valido"),
|
||||
province: optionalText(50),
|
||||
/** Vendita solo in Italia (regione Medusa "Italia"). */
|
||||
countryCode: z.literal("it").default("it"),
|
||||
phone: optionalText(30),
|
||||
})
|
||||
|
||||
/** Dati per la fattura aziendale (facoltativi): normalizzati in maiuscolo, senza spazi. */
|
||||
const upperCode = (max: number) => z.string().trim().toUpperCase().max(max).optional().transform((v) => v || null)
|
||||
const invoiceInput = z.object({
|
||||
companyName: z.string().trim().min(1).max(200),
|
||||
vatNumber: z
|
||||
.string()
|
||||
.transform(normalizeVatNumber)
|
||||
.refine(isValidItalianVat, "Partita IVA non valida"),
|
||||
fiscalCode: upperCode(16).refine((v) => v === null || FISCAL_CODE_PATTERN.test(v), "Codice fiscale non valido"),
|
||||
sdiCode: upperCode(7).refine((v) => v === null || SDI_CODE_PATTERN.test(v), "Codice destinatario non valido"),
|
||||
pec: z
|
||||
.string()
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.max(254)
|
||||
.optional()
|
||||
.transform((v) => v || null)
|
||||
.refine((v) => v === null || z.email().safeParse(v).success, "PEC non valida"),
|
||||
})
|
||||
|
||||
const handleParam = z.object({ handle: z.string().trim().min(1).max(200) })
|
||||
const itemParam = z.object({ id: z.string().min(1).max(100) })
|
||||
|
||||
/**
|
||||
* Proxy shop: il frontend parla solo con Fastify, che chiama la Store API Medusa (publishable key)
|
||||
* e le route interne. L'id del carrello sta nel cookie firmato `cart_id`; prezzi e carrello
|
||||
* dipendono dall'utente (header Authorization facoltativo).
|
||||
*/
|
||||
const shopRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, shop } = app
|
||||
|
||||
/** Viewer della richiesta (null se anonima). */
|
||||
const viewerOf = async (request: FastifyRequest): Promise<ShopViewer> => {
|
||||
if (!request.user) return null
|
||||
const user = await findUserById(app.db, request.user.sub)
|
||||
if (!user || user.disabledAt) throw new AppError(401, "UNAUTHORIZED", "Account non disponibile")
|
||||
return shop.viewerFor(user)
|
||||
}
|
||||
|
||||
/** Carrello della richiesta; se il cookie non porta a un carrello utilizzabile, lo cancella. */
|
||||
const currentCart = async (request: FastifyRequest, reply: FastifyReply, viewer: ShopViewer) => {
|
||||
const cartId = readCartCookie(request)
|
||||
const found = await shop.resolveCart(cartId, viewer)
|
||||
if (!found && request.cookies[CART_COOKIE]) clearCartCookie(reply, config)
|
||||
return found
|
||||
}
|
||||
|
||||
const requireCart = async (request: FastifyRequest, reply: FastifyReply, viewer: ShopViewer) => {
|
||||
const found = await currentCart(request, reply, viewer)
|
||||
if (!found) throw new AppError(404, "CART_NOT_FOUND", "Carrello non trovato")
|
||||
return found
|
||||
}
|
||||
|
||||
const auth = { onRequest: [app.optionalAuthenticate] }
|
||||
|
||||
// --- Catalogo (pubblico; prezzi riservati per installatori approvati) ---
|
||||
|
||||
app.get(
|
||||
"/shop/products",
|
||||
{
|
||||
...auth,
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
limit: z.coerce.number().int().min(1).max(50).default(12),
|
||||
offset: z.coerce.number().int().min(0).max(10_000).default(0),
|
||||
category: z.string().trim().min(1).max(200).optional(),
|
||||
q: z.string().trim().min(1).max(100).optional(),
|
||||
}),
|
||||
response: {
|
||||
200: z.object({
|
||||
items: z.array(productSummary),
|
||||
total: z.number().int(),
|
||||
limit: z.number().int(),
|
||||
offset: z.number().int(),
|
||||
}) satisfies z.ZodType<Paginated<ShopProductSummaryDto>>,
|
||||
},
|
||||
},
|
||||
},
|
||||
async (request) => shop.listProducts(request.query, await viewerOf(request)),
|
||||
)
|
||||
|
||||
app.get(
|
||||
"/shop/products/:handle",
|
||||
{ ...auth, schema: { params: handleParam, response: { 200: productDetail } } },
|
||||
async (request) => shop.getProduct(request.params.handle, await viewerOf(request)),
|
||||
)
|
||||
|
||||
app.get("/shop/categories", { schema: { response: { 200: z.array(category) } } }, async () => shop.listCategories())
|
||||
|
||||
// --- Carrello ---
|
||||
|
||||
// POST /shop/cart — restituisce il carrello aperto o ne crea uno (201)
|
||||
app.post("/shop/cart", { ...auth, schema: { response: { 200: cart, 201: cart } } }, async (request, reply) => {
|
||||
const viewer = await viewerOf(request)
|
||||
const existing = await currentCart(request, reply, viewer)
|
||||
if (existing) return toCartDto(existing)
|
||||
const created = await shop.createCart(viewer)
|
||||
setCartCookie(reply, config, created.id)
|
||||
return reply.status(201).send(toCartDto(created))
|
||||
})
|
||||
|
||||
app.get("/shop/cart", { ...auth, schema: { response: { 200: cart } } }, async (request, reply) => {
|
||||
return toCartDto(await requireCart(request, reply, await viewerOf(request)))
|
||||
})
|
||||
|
||||
// POST /shop/cart/items — aggiunge una variante (crea il carrello se non esiste)
|
||||
app.post(
|
||||
"/shop/cart/items",
|
||||
{
|
||||
...auth,
|
||||
schema: {
|
||||
body: z.object({ variantId: z.string().min(1).max(100), quantity: z.number().int().min(1).max(100).default(1) }),
|
||||
response: { 200: cart },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const viewer = await viewerOf(request)
|
||||
let target = await currentCart(request, reply, viewer)
|
||||
if (!target) {
|
||||
target = await shop.createCart(viewer)
|
||||
setCartCookie(reply, config, target.id)
|
||||
}
|
||||
return toCartDto(await shop.addItem(target, request.body.variantId, request.body.quantity))
|
||||
},
|
||||
)
|
||||
|
||||
app.patch(
|
||||
"/shop/cart/items/:id",
|
||||
{
|
||||
...auth,
|
||||
schema: { params: itemParam, body: z.object({ quantity: z.number().int().min(1).max(100) }), response: { 200: cart } },
|
||||
},
|
||||
async (request, reply) => {
|
||||
const current = await requireCart(request, reply, await viewerOf(request))
|
||||
return toCartDto(await shop.updateItem(current, request.params.id, request.body.quantity))
|
||||
},
|
||||
)
|
||||
|
||||
app.delete(
|
||||
"/shop/cart/items/:id",
|
||||
{ ...auth, schema: { params: itemParam, response: { 200: cart } } },
|
||||
async (request, reply) => {
|
||||
const current = await requireCart(request, reply, await viewerOf(request))
|
||||
return toCartDto(await shop.removeItem(current, request.params.id))
|
||||
},
|
||||
)
|
||||
|
||||
// POST /shop/cart/address — email (obbligatoria solo per il guest), indirizzi, dati per la fattura e consensi
|
||||
app.post(
|
||||
"/shop/cart/address",
|
||||
{
|
||||
...auth,
|
||||
// Ogni invio da guest crea o cerca un customer Medusa per l'email e registra i consensi
|
||||
config: { rateLimit: { max: 20, timeWindow: "1 minute" } },
|
||||
schema: {
|
||||
body: z.object({
|
||||
email: z.string().trim().toLowerCase().pipe(z.email("Email non valida")).optional(),
|
||||
shippingAddress: addressInput,
|
||||
/** Se assente coincide con l'indirizzo di spedizione. */
|
||||
billingAddress: addressInput.optional(),
|
||||
/** Fattura aziendale; null o assente = nessuna fattura (eventuali dati precedenti vengono tolti). */
|
||||
invoice: invoiceInput.nullable().optional(),
|
||||
/** Consenso al trattamento dei dati per l'ordine: obbligatorio. */
|
||||
acceptPrivacy: z.literal(true, "Il consenso al trattamento dei dati è obbligatorio"),
|
||||
acceptMarketing: z.boolean().optional(),
|
||||
}),
|
||||
response: { 200: cart },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const viewer = await viewerOf(request)
|
||||
const current = await requireCart(request, reply, viewer)
|
||||
const { email, acceptPrivacy: _privacy, acceptMarketing, ...data } = request.body
|
||||
const updated = await shop.setAddress(current, viewer, {
|
||||
...data,
|
||||
email: email ? normalizeEmail(email) : undefined,
|
||||
})
|
||||
// Consensi registrati una volta sola anche se l'indirizzo viene reinviato (stessa email, versione, origine)
|
||||
if (updated.email) {
|
||||
await recordConsentsOnce(app.db, {
|
||||
userId: viewer?.user.id ?? null,
|
||||
email: updated.email,
|
||||
version: config.PRIVACY_POLICY_VERSION,
|
||||
source: "checkout",
|
||||
ip: request.ip,
|
||||
marketing: acceptMarketing === true,
|
||||
})
|
||||
}
|
||||
return toCartDto(updated)
|
||||
},
|
||||
)
|
||||
|
||||
app.get(
|
||||
"/shop/cart/shipping-options",
|
||||
{ ...auth, schema: { response: { 200: z.array(shippingOption) } } },
|
||||
async (request, reply) => {
|
||||
const current = await requireCart(request, reply, await viewerOf(request))
|
||||
return shop.shippingOptions(current)
|
||||
},
|
||||
)
|
||||
|
||||
app.post(
|
||||
"/shop/cart/shipping",
|
||||
{ ...auth, schema: { body: z.object({ optionId: z.string().min(1).max(100) }), response: { 200: cart } } },
|
||||
async (request, reply) => {
|
||||
const current = await requireCart(request, reply, await viewerOf(request))
|
||||
return toCartDto(await shop.setShipping(current, request.body.optionId))
|
||||
},
|
||||
)
|
||||
|
||||
// POST /shop/cart/payment — payment session Stripe, restituisce il client_secret per Stripe.js
|
||||
app.post(
|
||||
"/shop/cart/payment",
|
||||
{ ...auth, config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, schema: { response: { 200: payment } } },
|
||||
async (request, reply) => {
|
||||
const current = await requireCart(request, reply, await viewerOf(request))
|
||||
const session = await shop.initPayment(current)
|
||||
return { provider: "stripe" as const, ...session }
|
||||
},
|
||||
)
|
||||
|
||||
// POST /shop/cart/complete — dopo la conferma Stripe lato client: crea l'ordine e chiude il carrello
|
||||
app.post(
|
||||
"/shop/cart/complete",
|
||||
{ ...auth, config: { rateLimit: { max: 20, timeWindow: "1 minute" } }, schema: { response: { 200: order } } },
|
||||
async (request, reply) => {
|
||||
const viewer = await viewerOf(request)
|
||||
const current = await requireCart(request, reply, viewer)
|
||||
const placed = await shop.complete(current)
|
||||
clearCartCookie(reply, config)
|
||||
request.log.info({ orderNumber: placed.number, userId: viewer?.user.id ?? null }, "ordine creato")
|
||||
return placed
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default shopRoutes
|
||||
@@ -0,0 +1,166 @@
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { showroomBookings } from "../../db/schema/index.js"
|
||||
import { formatDateTime } from "../../emails/html.js"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { SHOWROOM } from "../../showroom/config.js"
|
||||
import { addDays, bookableSlots, findBookableSlot, localDate } from "../../showroom/slots.js"
|
||||
import { findUserById, normalizeEmail } from "../../services/auth.js"
|
||||
import { recordConsents } from "../../services/consents.js"
|
||||
import { passesFormGuard } from "../../services/form-guard.js"
|
||||
import { notifyStaff } from "../../services/notifications.js"
|
||||
import { occupancy, reserveSeats, toShowroomBookingDto } from "../../services/showroom.js"
|
||||
import { messageResponse, personalDataFields } from "./contact.js"
|
||||
|
||||
export const showroomBookingStatus = z.enum(["requested", "confirmed", "cancelled", "completed"])
|
||||
|
||||
export const showroomBookingDto = z.object({
|
||||
id: z.uuid(),
|
||||
name: z.string(),
|
||||
email: z.string(),
|
||||
phone: z.string().nullable(),
|
||||
requestedAt: z.string(),
|
||||
people: z.number().int(),
|
||||
notes: z.string().nullable(),
|
||||
status: showroomBookingStatus,
|
||||
createdAt: z.string(),
|
||||
})
|
||||
|
||||
const slotsResponse = z.object({
|
||||
timeZone: z.string(),
|
||||
slotMinutes: z.number().int(),
|
||||
maxPeoplePerBooking: z.number().int(),
|
||||
days: z.array(
|
||||
z.object({
|
||||
date: z.string(),
|
||||
slots: z.array(z.object({ start: z.string(), end: z.string(), available: z.number().int() })),
|
||||
}),
|
||||
),
|
||||
})
|
||||
|
||||
/**
|
||||
* Showroom: slot disponibili e prenotazione delle visite. Orari, durata e capienza in src/showroom/config.ts.
|
||||
* La prenotazione viene confermata subito se c'è posto (stato `confirmed`, email di conferma); lo staff la vede
|
||||
* nel pannello e può annullarla o segnarla come completata.
|
||||
*/
|
||||
const showroomRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const { config, db } = app
|
||||
|
||||
// GET /showroom/slots?from=YYYY-MM-DD&days=14 — giorni aperti con gli slot prenotabili e i posti liberi
|
||||
app.get(
|
||||
"/showroom/slots",
|
||||
{
|
||||
schema: {
|
||||
querystring: z.object({
|
||||
from: z.iso.date().optional(),
|
||||
days: z.coerce.number().int().min(1).max(31).default(14),
|
||||
}),
|
||||
response: { 200: slotsResponse },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const now = new Date()
|
||||
const today = localDate(now, SHOWROOM.timeZone)
|
||||
const from = request.query.from && request.query.from > today ? request.query.from : today
|
||||
const days = bookableSlots(SHOWROOM, now, from, request.query.days)
|
||||
const booked = days.length
|
||||
? await occupancy(db, days[0]!.slots[0]!.start, new Date(`${addDays(days.at(-1)!.date, 2)}T00:00:00Z`))
|
||||
: new Map<number, number>()
|
||||
reply.header("cache-control", "no-store")
|
||||
return {
|
||||
timeZone: SHOWROOM.timeZone,
|
||||
slotMinutes: SHOWROOM.slotMinutes,
|
||||
maxPeoplePerBooking: SHOWROOM.maxPeoplePerBooking,
|
||||
days: days.map((d) => ({
|
||||
date: d.date,
|
||||
slots: d.slots.map((s) => ({
|
||||
start: s.start.toISOString(),
|
||||
end: s.end.toISOString(),
|
||||
available: Math.max(0, SHOWROOM.capacity - (booked.get(s.start.getTime()) ?? 0)),
|
||||
})),
|
||||
})),
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// POST /showroom/bookings — verifica lo slot e la capienza, salva confermata, email al cliente e allo staff
|
||||
app.post(
|
||||
"/showroom/bookings",
|
||||
{
|
||||
onRequest: [app.optionalAuthenticate],
|
||||
config: { rateLimit: { max: 5, timeWindow: "15 minutes" } },
|
||||
schema: {
|
||||
body: z.object({
|
||||
...personalDataFields,
|
||||
/** Inizio dello slot, come restituito da GET /showroom/slots. */
|
||||
requestedAt: z.iso.datetime({ offset: true }),
|
||||
people: z.number().int().min(1).max(SHOWROOM.maxPeoplePerBooking),
|
||||
notes: z.string().trim().min(1).max(1000).optional(),
|
||||
}),
|
||||
response: { 201: showroomBookingDto, 200: messageResponse },
|
||||
},
|
||||
},
|
||||
async (request, reply) => {
|
||||
const body = request.body
|
||||
if (!passesFormGuard(request, config.COOKIE_SECRET, body, "showroom")) {
|
||||
return reply.status(200).send({ message: "Richiesta ricevuta" })
|
||||
}
|
||||
|
||||
const slot = findBookableSlot(SHOWROOM, new Date(), new Date(body.requestedAt))
|
||||
if (!slot) throw new AppError(400, "INVALID_SLOT", "Orario non disponibile per le visite: scegline uno tra quelli proposti")
|
||||
|
||||
const user = request.user ? await findUserById(db, request.user.sub) : undefined
|
||||
const userId = user && !user.disabledAt ? user.id : null
|
||||
const email = normalizeEmail(body.email)
|
||||
|
||||
const booking = await db.transaction(async (tx) => {
|
||||
await reserveSeats(tx, slot.start, body.people, SHOWROOM.capacity)
|
||||
const [row] = await tx
|
||||
.insert(showroomBookings)
|
||||
.values({
|
||||
userId,
|
||||
name: body.name,
|
||||
email,
|
||||
phone: body.phone ?? null,
|
||||
requestedAt: slot.start,
|
||||
people: body.people,
|
||||
notes: body.notes ?? null,
|
||||
status: "confirmed",
|
||||
})
|
||||
.returning()
|
||||
await recordConsents(tx, {
|
||||
userId,
|
||||
email,
|
||||
version: config.PRIVACY_POLICY_VERSION,
|
||||
source: "showroom",
|
||||
ip: request.ip,
|
||||
marketing: body.acceptMarketing === true,
|
||||
})
|
||||
return row!
|
||||
})
|
||||
|
||||
await app.email.send({
|
||||
to: email,
|
||||
template: "showroom-booking-confirmed",
|
||||
data: { name: booking.name, requestedAt: booking.requestedAt.toISOString(), people: booking.people, notes: booking.notes },
|
||||
})
|
||||
await notifyStaff(app, {
|
||||
title: `Visita allo showroom - ${booking.name}`,
|
||||
intro: "È stata prenotata una visita allo showroom.",
|
||||
rows: [
|
||||
{ label: "Data e ora", value: formatDateTime(booking.requestedAt.toISOString()) },
|
||||
{ label: "Persone", value: String(booking.people) },
|
||||
{ label: "Nome", value: booking.name },
|
||||
{ label: "Email", value: email },
|
||||
{ label: "Telefono", value: booking.phone ?? "-" },
|
||||
{ label: "Note", value: booking.notes ?? "-" },
|
||||
],
|
||||
url: `${config.PRIVATE_APP_URL}/admin/showroom/${booking.id}`,
|
||||
})
|
||||
request.log.info({ showroomBookingId: booking.id, requestedAt: booking.requestedAt, userId }, "prenotazione showroom")
|
||||
return reply.status(201).send(toShowroomBookingDto(booking))
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default showroomRoutes
|
||||
@@ -0,0 +1,56 @@
|
||||
import type { FastifyPluginAsyncZod } from "fastify-type-provider-zod"
|
||||
import { z } from "zod"
|
||||
import { AppError } from "../../errors.js"
|
||||
import { handleMedusaWebhook, medusaWebhookPayload, verifyWebhookSignature } from "../../services/medusa-webhooks.js"
|
||||
|
||||
/**
|
||||
* POST /webhooks/medusa — eventi ordine dal subscriber Medusa, firmati HMAC (x-signature).
|
||||
* In questo plugin (incapsulato) il parser JSON verifica la firma sul body grezzo PRIMA del parse:
|
||||
* firma assente o errata → 401, il body non viene nemmeno interpretato.
|
||||
* Escluso dal rate limit globale (i retry di Medusa non devono essere bloccati) e dal CORS (non è
|
||||
* chiamato da browser). Risposte: 200 { status: processed | ignored | duplicate }; 500 se
|
||||
* l'elaborazione fallisce, così Medusa ritenta.
|
||||
*/
|
||||
const medusaWebhookRoutes: FastifyPluginAsyncZod = async (app) => {
|
||||
const secret = app.config.WEBHOOK_SECRET
|
||||
|
||||
app.removeAllContentTypeParsers()
|
||||
app.addContentTypeParser("application/json", { parseAs: "buffer" }, (request, body, done) => {
|
||||
const raw = body as Buffer
|
||||
if (!verifyWebhookSignature(raw, request.headers["x-signature"] as string | undefined, secret)) {
|
||||
request.log.warn({ ip: request.ip }, "webhook Medusa con firma non valida")
|
||||
done(new AppError(401, "INVALID_SIGNATURE", "Firma non valida"), undefined)
|
||||
return
|
||||
}
|
||||
try {
|
||||
done(null, JSON.parse(raw.toString("utf8")))
|
||||
} catch {
|
||||
done(new AppError(400, "BAD_REQUEST", "JSON non valido"), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
app.post(
|
||||
"/medusa",
|
||||
{
|
||||
config: { rateLimit: false, cors: false },
|
||||
schema: {
|
||||
body: medusaWebhookPayload,
|
||||
response: { 200: z.object({ status: z.enum(["processed", "ignored", "duplicate"]) }) },
|
||||
},
|
||||
},
|
||||
async (request) => {
|
||||
const payload = request.body
|
||||
const log = request.log.child({ webhookId: payload.id, event: payload.event })
|
||||
try {
|
||||
const status = await handleMedusaWebhook({ db: app.db, medusa: app.medusa, email: app.email, config: app.config, log }, payload)
|
||||
log.info({ status }, "webhook Medusa")
|
||||
return { status }
|
||||
} catch (err) {
|
||||
log.error({ err }, "elaborazione webhook Medusa fallita")
|
||||
throw new AppError(500, "WEBHOOK_PROCESSING_FAILED", "Elaborazione del webhook non riuscita")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
export default medusaWebhookRoutes
|
||||
@@ -0,0 +1,35 @@
|
||||
import { buildApp } from "./app.js"
|
||||
import { ConfigError, loadConfig } from "./config/env.js"
|
||||
|
||||
let config
|
||||
try {
|
||||
config = loadConfig()
|
||||
} catch (err) {
|
||||
if (err instanceof ConfigError) {
|
||||
console.error(`[api] ${err.message}\nControlla apps/api/.env (modello: apps/api/.env.example).`)
|
||||
process.exit(1)
|
||||
}
|
||||
throw err
|
||||
}
|
||||
|
||||
const app = await buildApp(config)
|
||||
|
||||
const shutdown = async (signal: string) => {
|
||||
app.log.info({ signal }, "arresto in corso")
|
||||
try {
|
||||
await app.close()
|
||||
process.exit(0)
|
||||
} catch (err) {
|
||||
app.log.error({ err }, "errore durante l'arresto")
|
||||
process.exit(1)
|
||||
}
|
||||
}
|
||||
process.once("SIGINT", () => void shutdown("SIGINT"))
|
||||
process.once("SIGTERM", () => void shutdown("SIGTERM"))
|
||||
|
||||
try {
|
||||
await app.listen({ host: config.HOST, port: config.PORT })
|
||||
} catch (err) {
|
||||
app.log.fatal({ err }, "avvio fallito")
|
||||
process.exit(1)
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
import { randomUUID } from "node:crypto"
|
||||
import type { UserDto } from "@iti/shared"
|
||||
import { and, eq, gt, isNull, sql } from "drizzle-orm"
|
||||
import type { DbOrTx as Database } from "../db/client.js"
|
||||
import { emailVerificationTokens, passwordResetTokens, refreshTokens, type User, users } from "../db/schema/index.js"
|
||||
import { hashPassword, verifyPassword } from "./password.js"
|
||||
import { generateToken, hashToken } from "./tokens.js"
|
||||
|
||||
export const PASSWORD_RESET_TTL_MS = 60 * 60 * 1000 // 1 ora
|
||||
export const EMAIL_VERIFICATION_TTL_MS = 48 * 60 * 60 * 1000 // 48 ore
|
||||
|
||||
/** Normalizzazione unica delle email (vincolo DB: email minuscola). */
|
||||
export const normalizeEmail = (email: string) => email.trim().toLowerCase()
|
||||
|
||||
export function toUserDto(user: User): UserDto {
|
||||
return {
|
||||
id: user.id,
|
||||
email: user.email,
|
||||
role: user.role,
|
||||
firstName: user.firstName,
|
||||
lastName: user.lastName,
|
||||
companyName: user.companyName,
|
||||
vatNumber: user.vatNumber,
|
||||
emailVerified: user.emailVerifiedAt !== null,
|
||||
createdAt: user.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export async function findUserByEmail(db: Database, email: string): Promise<User | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.email, normalizeEmail(email)))
|
||||
return user
|
||||
}
|
||||
|
||||
export async function findUserById(db: Database, id: string): Promise<User | undefined> {
|
||||
const [user] = await db.select().from(users).where(eq(users.id, id))
|
||||
return user
|
||||
}
|
||||
|
||||
/** Hash argon2id calcolato una volta: verificato quando l'utente non esiste, per uniformare i tempi. */
|
||||
let dummyHash: Promise<string> | undefined
|
||||
const getDummyHash = () => (dummyHash ??= hashPassword(randomUUID()))
|
||||
|
||||
/**
|
||||
* Verifica le credenziali. Restituisce l'utente solo se esiste, non è disabilitato e la password
|
||||
* è corretta; in tutti gli altri casi null, con tempi di risposta simili (niente enumerazione email).
|
||||
*/
|
||||
export async function checkCredentials(db: Database, email: string, password: string): Promise<User | null> {
|
||||
const user = await findUserByEmail(db, email)
|
||||
if (!user) {
|
||||
await verifyPassword(await getDummyHash(), password)
|
||||
return null
|
||||
}
|
||||
const ok = await verifyPassword(user.passwordHash, password)
|
||||
return ok && !user.disabledAt ? user : null
|
||||
}
|
||||
|
||||
// --- Refresh token ---
|
||||
|
||||
export type ClientMeta = { userAgent?: string | undefined; ip?: string | undefined }
|
||||
|
||||
/** Crea un refresh token (nuova family se familyId non indicato). In DB solo l'hash. */
|
||||
export async function issueRefreshToken(
|
||||
db: Database,
|
||||
userId: string,
|
||||
ttlDays: number,
|
||||
meta: ClientMeta,
|
||||
familyId: string = randomUUID(),
|
||||
): Promise<{ token: string; expiresAt: Date }> {
|
||||
const { token, hash } = generateToken()
|
||||
const expiresAt = new Date(Date.now() + ttlDays * 24 * 60 * 60 * 1000)
|
||||
await db.insert(refreshTokens).values({
|
||||
userId,
|
||||
tokenHash: hash,
|
||||
familyId,
|
||||
expiresAt,
|
||||
userAgent: meta.userAgent?.slice(0, 512) ?? null,
|
||||
ip: meta.ip ?? null,
|
||||
})
|
||||
return { token, expiresAt }
|
||||
}
|
||||
|
||||
export async function revokeFamily(db: Database, familyId: string): Promise<void> {
|
||||
await db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(refreshTokens.familyId, familyId), isNull(refreshTokens.revokedAt)))
|
||||
}
|
||||
|
||||
export async function revokeAllUserTokens(db: Database, userId: string): Promise<void> {
|
||||
await db
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(refreshTokens.userId, userId), isNull(refreshTokens.revokedAt)))
|
||||
}
|
||||
|
||||
export type RotateResult =
|
||||
| { ok: true; user: User; token: string; expiresAt: Date }
|
||||
| { ok: false; reason: "unknown" | "expired" | "reused" | "disabled" }
|
||||
|
||||
/**
|
||||
* Rotazione: il token presentato viene revocato e sostituito da uno nuovo della stessa family.
|
||||
* Se il token era già revocato (o viene usato due volte in concorrenza) è un riuso: si revoca
|
||||
* l'intera family, così anche chi l'ha rubato perde l'accesso.
|
||||
*/
|
||||
export async function rotateRefreshToken(
|
||||
db: Database,
|
||||
token: string,
|
||||
ttlDays: number,
|
||||
meta: ClientMeta,
|
||||
): Promise<RotateResult> {
|
||||
const [current] = await db.select().from(refreshTokens).where(eq(refreshTokens.tokenHash, hashToken(token)))
|
||||
if (!current) return { ok: false, reason: "unknown" }
|
||||
|
||||
if (current.revokedAt) {
|
||||
await revokeFamily(db, current.familyId)
|
||||
return { ok: false, reason: "reused" }
|
||||
}
|
||||
if (current.expiresAt <= new Date()) return { ok: false, reason: "expired" }
|
||||
|
||||
const user = await findUserById(db, current.userId)
|
||||
if (!user || user.disabledAt) {
|
||||
await revokeFamily(db, current.familyId)
|
||||
return { ok: false, reason: "disabled" }
|
||||
}
|
||||
|
||||
return db.transaction(async (tx) => {
|
||||
// Revoca condizionata: se un'altra richiesta l'ha appena revocato, 0 righe → riuso
|
||||
const revoked = await tx
|
||||
.update(refreshTokens)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(eq(refreshTokens.id, current.id), isNull(refreshTokens.revokedAt)))
|
||||
.returning({ id: refreshTokens.id })
|
||||
if (revoked.length === 0) {
|
||||
await revokeFamily(tx, current.familyId)
|
||||
return { ok: false, reason: "reused" } as const
|
||||
}
|
||||
const next = await issueRefreshToken(tx, user.id, ttlDays, meta, current.familyId)
|
||||
return { ok: true, user, ...next } as const
|
||||
})
|
||||
}
|
||||
|
||||
/** Logout: revoca la family del token presentato (solo questa sessione/dispositivo). */
|
||||
export async function revokeByToken(db: Database, token: string): Promise<void> {
|
||||
const [current] = await db
|
||||
.select({ familyId: refreshTokens.familyId })
|
||||
.from(refreshTokens)
|
||||
.where(eq(refreshTokens.tokenHash, hashToken(token)))
|
||||
if (current) await revokeFamily(db, current.familyId)
|
||||
}
|
||||
|
||||
// --- Token monouso (reset password, verifica email) ---
|
||||
|
||||
type OneTimeTable = typeof passwordResetTokens | typeof emailVerificationTokens
|
||||
|
||||
/** Crea un token monouso invalidando quelli precedenti non usati dello stesso tipo. */
|
||||
export async function createOneTimeToken(
|
||||
db: Database,
|
||||
table: OneTimeTable,
|
||||
userId: string,
|
||||
ttlMs: number,
|
||||
): Promise<string> {
|
||||
const { token, hash } = generateToken()
|
||||
await db.transaction(async (tx) => {
|
||||
await tx.delete(table).where(and(eq(table.userId, userId), isNull(table.usedAt)))
|
||||
await tx.insert(table).values({ userId, tokenHash: hash, expiresAt: new Date(Date.now() + ttlMs) })
|
||||
})
|
||||
return token
|
||||
}
|
||||
|
||||
/** Consuma un token monouso valido (non usato, non scaduto). Restituisce lo userId o null. */
|
||||
export async function consumeOneTimeToken(db: Database, table: OneTimeTable, token: string): Promise<string | null> {
|
||||
const [row] = await db
|
||||
.update(table)
|
||||
.set({ usedAt: new Date() })
|
||||
.where(and(eq(table.tokenHash, hashToken(token)), isNull(table.usedAt), gt(table.expiresAt, sql`now()`)))
|
||||
.returning({ userId: table.userId })
|
||||
return row?.userId ?? null
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import type { BlogCategoryDto, BlogPostAdminDto, BlogPostDetailDto, BlogPostSummaryDto } from "@iti/shared"
|
||||
import { like, or, eq } from "drizzle-orm"
|
||||
import { Marked } from "marked"
|
||||
import sanitizeHtml from "sanitize-html"
|
||||
import type { DbOrTx as Database } from "../db/client.js"
|
||||
import type { BlogPost } from "../db/schema/index.js"
|
||||
import { blogCategories, blogPosts } from "../db/schema/index.js"
|
||||
|
||||
// --- Markdown → HTML sicuro ---
|
||||
|
||||
/** Istanza dedicata (nessuna modifica alle opzioni globali di marked). GFM: tabelle, barrato, autolink. */
|
||||
const markdown = new Marked({ gfm: true, breaks: false, async: false })
|
||||
|
||||
/**
|
||||
* Whitelist dell'HTML ammesso nei post. Tutto il resto (script, style, iframe, form, attributi on*,
|
||||
* style inline, URL javascript:/data:) viene rimosso. I link esterni ricevono rel="noopener noreferrer".
|
||||
*/
|
||||
const SANITIZE: sanitizeHtml.IOptions = {
|
||||
allowedTags: [
|
||||
"h1", "h2", "h3", "h4", "h5", "h6", "p", "br", "hr", "blockquote", "pre", "code",
|
||||
"ul", "ol", "li", "strong", "em", "b", "i", "del", "s", "sup", "sub", "a", "img",
|
||||
"table", "thead", "tbody", "tr", "th", "td", "figure", "figcaption",
|
||||
],
|
||||
allowedAttributes: {
|
||||
a: ["href", "title", "rel", "target"],
|
||||
img: ["src", "alt", "title", "width", "height", "loading"],
|
||||
th: ["align"],
|
||||
td: ["align"],
|
||||
ol: ["start"],
|
||||
code: ["class"],
|
||||
},
|
||||
allowedClasses: { code: [/^language-[a-z0-9+#-]+$/] },
|
||||
allowedSchemes: ["http", "https", "mailto", "tel"],
|
||||
allowedSchemesByTag: { img: ["http", "https"] },
|
||||
allowedSchemesAppliedToAttributes: ["href", "src"],
|
||||
allowProtocolRelative: false,
|
||||
disallowedTagsMode: "discard",
|
||||
transformTags: {
|
||||
a: (tagName, attribs) => {
|
||||
const external = /^https?:\/\//i.test(attribs.href ?? "")
|
||||
return {
|
||||
tagName,
|
||||
attribs: external
|
||||
? { ...attribs, rel: "noopener noreferrer", target: "_blank" }
|
||||
: Object.fromEntries(Object.entries(attribs).filter(([k]) => k !== "target")),
|
||||
}
|
||||
},
|
||||
img: (tagName, attribs) => ({ tagName, attribs: { ...attribs, loading: "lazy" } }),
|
||||
},
|
||||
}
|
||||
|
||||
/** Markdown → HTML renderizzato con marked e sanitizzato con sanitize-html (whitelist). */
|
||||
export function renderMarkdown(md: string): string {
|
||||
return sanitizeHtml(markdown.parse(md) as string, SANITIZE)
|
||||
}
|
||||
|
||||
// --- Slug ---
|
||||
|
||||
/** "Pompe di calore: guida 2026!" → "pompe-di-calore-guida-2026" (minuscolo, senza accenti, max 80). */
|
||||
export function slugify(text: string): string {
|
||||
const slug = text
|
||||
.normalize("NFKD")
|
||||
.replace(/[̀-ͯ]/g, "")
|
||||
.toLowerCase()
|
||||
.replace(/['’]/g, "")
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/^-+|-+$/g, "")
|
||||
.slice(0, 80)
|
||||
.replace(/-+$/g, "")
|
||||
return slug || "post"
|
||||
}
|
||||
|
||||
export const SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/
|
||||
|
||||
type SlugTable = typeof blogPosts | typeof blogCategories
|
||||
|
||||
/**
|
||||
* Slug libero a partire da `base`: base, base-2, base-3, ... (escluso l'elemento `exceptId`).
|
||||
* Il vincolo unique in DB resta la garanzia finale: chi inserisce ritenta sul conflitto.
|
||||
*/
|
||||
export async function uniqueSlug(db: Database, table: SlugTable, base: string, exceptId?: string): Promise<string> {
|
||||
const rows = await db
|
||||
.select({ id: table.id, slug: table.slug })
|
||||
.from(table)
|
||||
.where(or(eq(table.slug, base), like(table.slug, `${base}-%`)))
|
||||
const taken = new Set(rows.filter((r) => r.id !== exceptId).map((r) => r.slug))
|
||||
if (!taken.has(base)) return base
|
||||
for (let n = 2; ; n++) {
|
||||
const candidate = `${base.slice(0, 80 - String(n).length - 1)}-${n}`
|
||||
if (!taken.has(candidate)) return candidate
|
||||
}
|
||||
}
|
||||
|
||||
/** Violazione di un vincolo unique Postgres (es. slug inserito in concorrenza). */
|
||||
export const isUniqueViolation = (err: unknown) =>
|
||||
(err as { code?: string })?.code === "23505" || (err as { cause?: { code?: string } })?.cause?.code === "23505"
|
||||
|
||||
// --- DTO ---
|
||||
|
||||
type Category = typeof blogCategories.$inferSelect
|
||||
|
||||
export const toCategoryDto = (c: Category): BlogCategoryDto => ({
|
||||
id: c.id,
|
||||
slug: c.slug,
|
||||
name: c.name,
|
||||
description: c.description,
|
||||
})
|
||||
|
||||
export function toSummaryDto(p: BlogPost, category: Category | null, coverUrl: (path: string) => string): BlogPostSummaryDto {
|
||||
return {
|
||||
id: p.id,
|
||||
slug: p.slug,
|
||||
title: p.title,
|
||||
excerpt: p.excerpt,
|
||||
coverUrl: p.coverPath ? coverUrl(p.coverPath) : null,
|
||||
category: category ? toCategoryDto(category) : null,
|
||||
publishedAt: p.publishedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toDetailDto(p: BlogPost, category: Category | null, coverUrl: (path: string) => string): BlogPostDetailDto {
|
||||
return {
|
||||
...toSummaryDto(p, category, coverUrl),
|
||||
contentMd: p.contentMd,
|
||||
contentHtml: renderMarkdown(p.contentMd),
|
||||
seoTitle: p.seoTitle,
|
||||
seoDescription: p.seoDescription,
|
||||
updatedAt: p.updatedAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
export const toAdminDto = (p: BlogPost, category: Category | null, coverUrl: (path: string) => string): BlogPostAdminDto => ({
|
||||
...toDetailDto(p, category, coverUrl),
|
||||
status: p.status,
|
||||
})
|
||||
@@ -0,0 +1,31 @@
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import type { Config } from "../config/env.js"
|
||||
import { CART_COOKIE, CART_COOKIE_MAX_AGE_S } from "./shop.js"
|
||||
|
||||
/**
|
||||
* Cookie del carrello: id Medusa firmato (COOKIE_SECRET), httpOnly, path "/" (arriva anche a
|
||||
* /auth/login per associare il carrello guest al login), stesso dominio del refresh token.
|
||||
*/
|
||||
const options = (config: Config) => ({
|
||||
httpOnly: true,
|
||||
secure: config.AUTH_COOKIE_SECURE,
|
||||
sameSite: "lax" as const,
|
||||
path: "/",
|
||||
...(config.AUTH_COOKIE_DOMAIN ? { domain: config.AUTH_COOKIE_DOMAIN } : {}),
|
||||
})
|
||||
|
||||
/** Id del carrello dal cookie, solo se la firma è valida. */
|
||||
export function readCartCookie(request: FastifyRequest): string | undefined {
|
||||
const raw = request.cookies[CART_COOKIE]
|
||||
if (!raw) return undefined
|
||||
const unsigned = request.unsignCookie(raw)
|
||||
return unsigned.valid && unsigned.value ? unsigned.value : undefined
|
||||
}
|
||||
|
||||
export function setCartCookie(reply: FastifyReply, config: Config, cartId: string) {
|
||||
reply.setCookie(CART_COOKIE, cartId, { ...options(config), signed: true, maxAge: CART_COOKIE_MAX_AGE_S })
|
||||
}
|
||||
|
||||
export function clearCartCookie(reply: FastifyReply, config: Config) {
|
||||
reply.clearCookie(CART_COOKIE, options(config))
|
||||
}
|
||||
@@ -0,0 +1,85 @@
|
||||
import type { ConsentDto } from "@iti/shared"
|
||||
import { and, desc, eq, isNull, or, type SQL } from "drizzle-orm"
|
||||
import type { Database } from "../db/client.js"
|
||||
import { type Consent, consents, type User } from "../db/schema/index.js"
|
||||
|
||||
/** Transazione o DB: le funzioni si usano anche dentro db.transaction. */
|
||||
type Executor = Pick<Database, "insert" | "select" | "update">
|
||||
|
||||
/**
|
||||
* Registro consensi GDPR: ogni form con dati personali salva il consenso privacy (obbligatorio) e,
|
||||
* se dato, quello marketing (facoltativo, revocabile), con versione dell'informativa, origine, IP e timestamp.
|
||||
* Le righe non si cancellano: la revoca valorizza revoked_at.
|
||||
*/
|
||||
export async function recordConsents(
|
||||
tx: Executor,
|
||||
input: { userId: string | null; email: string | null; version: string; source: string; ip: string; marketing: boolean },
|
||||
): Promise<void> {
|
||||
const base = { userId: input.userId, email: input.email, version: input.version, source: input.source, ip: input.ip }
|
||||
await tx.insert(consents).values([
|
||||
{ ...base, type: "privacy" as const },
|
||||
...(input.marketing ? [{ ...base, type: "marketing" as const }] : []),
|
||||
])
|
||||
}
|
||||
|
||||
/**
|
||||
* Come recordConsents, ma senza duplicati per form che si possono reinviare (es. indirizzo del checkout):
|
||||
* il consenso privacy si salva solo se non c'è già, attivo, con stessa email (o utente), versione e origine;
|
||||
* quello marketing solo se non ce n'è già uno attivo per la stessa email (o utente).
|
||||
*/
|
||||
export async function recordConsentsOnce(
|
||||
tx: Executor,
|
||||
input: { userId: string | null; email: string; version: string; source: string; ip: string; marketing: boolean },
|
||||
): Promise<void> {
|
||||
const subject = input.userId ? eq(consents.userId, input.userId) : and(isNull(consents.userId), eq(consents.email, input.email))
|
||||
const active = async (type: "privacy" | "marketing", extra?: SQL) => {
|
||||
const [row] = await tx
|
||||
.select({ id: consents.id })
|
||||
.from(consents)
|
||||
.where(and(subject, eq(consents.type, type), isNull(consents.revokedAt), extra))
|
||||
.limit(1)
|
||||
return row !== undefined
|
||||
}
|
||||
const base = { userId: input.userId, email: input.email, version: input.version, source: input.source, ip: input.ip }
|
||||
const rows: (typeof consents.$inferInsert)[] = []
|
||||
if (!(await active("privacy", and(eq(consents.version, input.version), eq(consents.source, input.source))))) {
|
||||
rows.push({ ...base, type: "privacy" })
|
||||
}
|
||||
if (input.marketing && !(await active("marketing"))) rows.push({ ...base, type: "marketing" })
|
||||
if (rows.length > 0) await tx.insert(consents).values(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Consensi riferiti all'utente: quelli collegati all'account e, se l'email è verificata, quelli dati
|
||||
* da anonimo con la stessa email (stesso principio del collegamento delle richieste anonime).
|
||||
*/
|
||||
function ownedBy(user: User) {
|
||||
return user.emailVerifiedAt
|
||||
? or(eq(consents.userId, user.id), and(isNull(consents.userId), eq(consents.email, user.email)))
|
||||
: eq(consents.userId, user.id)
|
||||
}
|
||||
|
||||
export async function listUserConsents(db: Executor, user: User): Promise<Consent[]> {
|
||||
return db.select().from(consents).where(ownedBy(user)).orderBy(desc(consents.acceptedAt))
|
||||
}
|
||||
|
||||
/** Revoca tutti i consensi marketing attivi dell'utente; restituisce quanti ne ha revocati. */
|
||||
export async function revokeMarketing(db: Executor, user: User): Promise<number> {
|
||||
const revoked = await db
|
||||
.update(consents)
|
||||
.set({ revokedAt: new Date() })
|
||||
.where(and(ownedBy(user), eq(consents.type, "marketing"), isNull(consents.revokedAt)))
|
||||
.returning({ id: consents.id })
|
||||
return revoked.length
|
||||
}
|
||||
|
||||
export function toConsentDto(c: Consent): ConsentDto {
|
||||
return {
|
||||
id: c.id,
|
||||
type: c.type,
|
||||
version: c.version,
|
||||
source: c.source,
|
||||
acceptedAt: c.acceptedAt.toISOString(),
|
||||
revokedAt: c.revokedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { FastifyRequest } from "fastify"
|
||||
import type { Database } from "../db/client.js"
|
||||
import type { User } from "../db/schema/index.js"
|
||||
import { AppError } from "../errors.js"
|
||||
import { findUserById } from "./auth.js"
|
||||
|
||||
/**
|
||||
* Utente della richiesta autenticata, riletto dal DB. Utente cancellato o disabilitato → 401
|
||||
* (il token può essere ancora valido per qualche minuto dopo la disabilitazione).
|
||||
*/
|
||||
export async function requireActiveUser(db: Database, request: FastifyRequest): Promise<User> {
|
||||
const user = request.user ? await findUserById(db, request.user.sub) : undefined
|
||||
if (!user || user.disabledAt) throw new AppError(401, "UNAUTHORIZED", "Account non disponibile")
|
||||
return user
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import type { DocumentDto, DocumentVisibility } from "@iti/shared"
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyReply, FastifyRequest } from "fastify"
|
||||
import type { Database } from "../db/client.js"
|
||||
import { type Document, installerProfiles, type User } from "../db/schema/index.js"
|
||||
import { AppError } from "../errors.js"
|
||||
import { checkFileKind, FILE_KINDS, type FileKindName, newStorageKey, type StorageArea, type StorageService } from "./storage.js"
|
||||
|
||||
/**
|
||||
* Livelli di visibilità accessibili:
|
||||
* - anonimo → public;
|
||||
* - customer / company → public, customer;
|
||||
* - installer → anche installer, ma solo con profilo APPROVATO (altrimenti come un cliente);
|
||||
* - admin → tutti.
|
||||
* Un utente disabilitato è trattato come anonimo.
|
||||
*/
|
||||
export async function visibleLevels(db: Database, user: User | undefined): Promise<DocumentVisibility[]> {
|
||||
if (!user || user.disabledAt) return ["public"]
|
||||
if (user.role === "admin") return ["public", "customer", "installer", "admin"]
|
||||
if (user.role === "installer") {
|
||||
const [profile] = await db
|
||||
.select({ status: installerProfiles.status })
|
||||
.from(installerProfiles)
|
||||
.where(eq(installerProfiles.userId, user.id))
|
||||
if (profile?.status === "approved") return ["public", "customer", "installer"]
|
||||
}
|
||||
return ["public", "customer"]
|
||||
}
|
||||
|
||||
export function toDocumentDto(d: Document): DocumentDto {
|
||||
return {
|
||||
id: d.id,
|
||||
title: d.title,
|
||||
category: d.category,
|
||||
mimeType: d.mimeType,
|
||||
sizeBytes: d.sizeBytes,
|
||||
visibility: d.visibility,
|
||||
productRef: d.productRef,
|
||||
createdAt: d.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Nome del file scaricato: titolo ripulito + estensione del file salvato. */
|
||||
export function downloadFilename(title: string, filePath: string): string {
|
||||
const ext = filePath.slice(filePath.lastIndexOf("."))
|
||||
const base = title
|
||||
.normalize("NFKC")
|
||||
.replace(/[\\/:*?"<>|\x00-\x1f\x7f]+/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim()
|
||||
.slice(0, 120)
|
||||
return `${base || "documento"}${ext}`
|
||||
}
|
||||
|
||||
/** Content-Disposition attachment con fallback ASCII e nome UTF-8 (RFC 6266 / 5987). */
|
||||
export function contentDisposition(filename: string): string {
|
||||
const ascii = filename.normalize("NFKD").replace(/[^\x20-\x7e]/g, "").replace(/["\\]/g, "_") || "documento"
|
||||
return `attachment; filename="${ascii}"; filename*=UTF-8''${encodeURIComponent(filename)}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Invia in stream il file (area private) di un documento: Content-Type salvato, download come
|
||||
* allegato con `filename`, cache privata salvo `publicCache`. 404 se il file manca nello storage.
|
||||
*/
|
||||
export async function sendDocumentFile(
|
||||
request: FastifyRequest,
|
||||
reply: FastifyReply,
|
||||
storage: StorageService,
|
||||
doc: Pick<Document, "id" | "filePath" | "mimeType">,
|
||||
filename: string,
|
||||
publicCache = false,
|
||||
) {
|
||||
const file = await storage.get("private", doc.filePath)
|
||||
if (!file) {
|
||||
request.log.error({ documentId: doc.id }, "file del documento mancante nello storage")
|
||||
throw new AppError(404, "DOCUMENT_NOT_FOUND", "File non disponibile")
|
||||
}
|
||||
return reply
|
||||
.header("content-type", doc.mimeType)
|
||||
.header("content-length", file.size)
|
||||
.header("content-disposition", contentDisposition(filename))
|
||||
.header("cache-control", publicCache ? "public, max-age=300" : "private, no-store")
|
||||
.send(file.stream)
|
||||
}
|
||||
|
||||
/** Limite per IP sulle route di upload (solo admin, ma ogni upload scrive su disco fino a UPLOAD_MAX_BYTES). */
|
||||
export const UPLOAD_RATE_LIMIT = { max: 30, timeWindow: "1 minute" }
|
||||
|
||||
export type ReceivedUpload = {
|
||||
key: string
|
||||
area: StorageArea
|
||||
kind: FileKindName
|
||||
mime: string
|
||||
size: number
|
||||
/** Campi testuali del form multipart (gli altri file vengono rifiutati). */
|
||||
fields: Record<string, string>
|
||||
}
|
||||
|
||||
/**
|
||||
* Riceve un upload multipart con un solo file:
|
||||
* 1. estensione non ammessa → 415 senza scrivere nulla;
|
||||
* 2. scrittura sotto una chiave generata dal server (uuid, mai il nome originale);
|
||||
* 3. dimensione oltre UPLOAD_MAX_BYTES → 413; contenuto (magic bytes) o MIME dichiarato non
|
||||
* coerenti con l'estensione → 415. In entrambi i casi il file scritto viene cancellato.
|
||||
*/
|
||||
export async function receiveUpload(
|
||||
request: FastifyRequest,
|
||||
storage: StorageService,
|
||||
opts: { area: StorageArea; prefix: string; allowed: readonly FileKindName[]; maxBytes: number },
|
||||
): Promise<ReceivedUpload> {
|
||||
if (!request.isMultipart()) throw new AppError(415, "UNSUPPORTED_MEDIA_TYPE", "Richiesta multipart/form-data attesa")
|
||||
|
||||
const fields: Record<string, string> = {}
|
||||
let stored: Omit<ReceivedUpload, "fields"> | undefined
|
||||
let rejection: AppError | undefined
|
||||
|
||||
try {
|
||||
for await (const part of request.parts()) {
|
||||
if (part.type === "field") {
|
||||
if (typeof part.value === "string") fields[part.fieldname] = part.value
|
||||
continue
|
||||
}
|
||||
// Parte file: se già rifiutata o già ricevuta, consuma lo stream e scarta
|
||||
if (stored || rejection) {
|
||||
part.file.resume()
|
||||
rejection ??= new AppError(400, "TOO_MANY_FILES", "Un solo file per richiesta")
|
||||
continue
|
||||
}
|
||||
const ext = part.filename.slice(part.filename.lastIndexOf(".")).toLowerCase()
|
||||
const claimed = opts.allowed.find((k) => (FILE_KINDS[k].exts as readonly string[]).includes(ext))
|
||||
if (!claimed || !part.filename.includes(".")) {
|
||||
part.file.resume()
|
||||
rejection = new AppError(415, "UNSUPPORTED_FILE_TYPE", "Tipo di file non consentito")
|
||||
continue
|
||||
}
|
||||
const key = newStorageKey(opts.prefix, FILE_KINDS[claimed].exts[0])
|
||||
let object
|
||||
try {
|
||||
object = await storage.put(opts.area, key, part.file)
|
||||
} catch (err) {
|
||||
if ((err as { code?: string }).code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
rejection = new AppError(413, "FILE_TOO_LARGE", `File troppo grande (max ${Math.floor(opts.maxBytes / 1024 / 1024)} MB)`)
|
||||
continue
|
||||
}
|
||||
throw err
|
||||
}
|
||||
const check = checkFileKind(part.filename, part.mimetype, object.head, opts.allowed)
|
||||
if (!check.ok || object.size === 0) {
|
||||
await storage.delete(opts.area, key)
|
||||
request.log.info({ reason: check.ok ? "file vuoto" : check.reason }, "upload rifiutato")
|
||||
rejection = new AppError(415, "UNSUPPORTED_FILE_TYPE", "Tipo di file non consentito o contenuto non valido")
|
||||
continue
|
||||
}
|
||||
stored = { key, area: opts.area, kind: check.kind, mime: FILE_KINDS[check.kind].mime, size: object.size }
|
||||
}
|
||||
} catch (err) {
|
||||
if (stored) await storage.delete(stored.area, stored.key)
|
||||
const code = (err as { code?: string }).code
|
||||
if (code === "FST_REQ_FILE_TOO_LARGE") {
|
||||
throw new AppError(413, "FILE_TOO_LARGE", `File troppo grande (max ${Math.floor(opts.maxBytes / 1024 / 1024)} MB)`)
|
||||
}
|
||||
if (code === "FST_FILES_LIMIT") throw new AppError(400, "TOO_MANY_FILES", "Un solo file per richiesta")
|
||||
if (code === "FST_PARTS_LIMIT" || code === "FST_FIELDS_LIMIT") throw new AppError(400, "BAD_REQUEST", "Troppi campi nel form")
|
||||
throw err
|
||||
}
|
||||
|
||||
if (rejection) {
|
||||
if (stored) await storage.delete(stored.area, stored.key)
|
||||
throw rejection
|
||||
}
|
||||
if (!stored) throw new AppError(400, "FILE_REQUIRED", "Nessun file nel form (campo file)")
|
||||
return { ...stored, fields }
|
||||
}
|
||||
@@ -0,0 +1,229 @@
|
||||
import { mkdir, writeFile } from "node:fs/promises"
|
||||
import { join, resolve } from "node:path"
|
||||
import type { FastifyBaseLogger } from "fastify"
|
||||
import type { Config } from "../config/env.js"
|
||||
import { type EmailTemplate, type EmailTemplates, renderEmail } from "../emails/index.js"
|
||||
|
||||
/** Richiesta di invio: destinatario, template e relativi dati (tipizzati per template). */
|
||||
export type EmailRequest<T extends EmailTemplate = EmailTemplate> = { to: string; template: T; data: EmailTemplates[T] }
|
||||
|
||||
/** Messaggio pronto per il trasporto. `tag` = nome del template. */
|
||||
export type EmailMessage = { to: string; tag: EmailTemplate; subject: string; html: string; text: string }
|
||||
|
||||
/**
|
||||
* Invio email transazionali (`app.email`). `send` renderizza subito (un errore di template è un bug
|
||||
* e viene lanciato) e consegna in background: un errore del provider viene loggato e non fa fallire
|
||||
* la richiesta HTTP che ha generato l'email. `flush` attende le consegne in corso.
|
||||
*/
|
||||
export interface EmailService {
|
||||
send<T extends EmailTemplate>(request: EmailRequest<T>): Promise<void>
|
||||
flush(): Promise<void>
|
||||
}
|
||||
|
||||
/** Trasporto verso un provider: lancia in caso di errore. */
|
||||
export interface EmailTransport {
|
||||
readonly name: string
|
||||
deliver(message: EmailMessage): Promise<void>
|
||||
}
|
||||
|
||||
/** Errore del provider; `retryable` per errori di rete, 429 e 5xx. */
|
||||
export class EmailDeliveryError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly retryable: boolean,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "EmailDeliveryError"
|
||||
}
|
||||
}
|
||||
|
||||
export function renderMessage<T extends EmailTemplate>(request: EmailRequest<T>): EmailMessage {
|
||||
const rendered = renderEmail(request.template, request.data)
|
||||
return { to: request.to, tag: request.template, ...rendered }
|
||||
}
|
||||
|
||||
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms))
|
||||
|
||||
export class EmailSender implements EmailService {
|
||||
private readonly pending = new Set<Promise<void>>()
|
||||
|
||||
constructor(
|
||||
private readonly transport: EmailTransport,
|
||||
private readonly log: FastifyBaseLogger,
|
||||
private readonly options: { attempts?: number; retryBaseMs?: number } = {},
|
||||
) {}
|
||||
|
||||
async send<T extends EmailTemplate>(request: EmailRequest<T>): Promise<void> {
|
||||
const message = renderMessage(request)
|
||||
const p = this.deliver(message).finally(() => this.pending.delete(p))
|
||||
this.pending.add(p)
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {
|
||||
while (this.pending.size > 0) await Promise.allSettled([...this.pending])
|
||||
}
|
||||
|
||||
/** Fino a `attempts` tentativi (default 3) con backoff esponenziale sugli errori ritentabili. */
|
||||
private async deliver(message: EmailMessage): Promise<void> {
|
||||
const attempts = this.options.attempts ?? 3
|
||||
const base = this.options.retryBaseMs ?? 1000
|
||||
const meta = { to: message.to, template: message.tag, provider: this.transport.name }
|
||||
for (let attempt = 1; ; attempt++) {
|
||||
try {
|
||||
await this.transport.deliver(message)
|
||||
this.log.info({ email: meta }, "email inviata")
|
||||
return
|
||||
} catch (err) {
|
||||
const retryable = err instanceof EmailDeliveryError ? err.retryable : false
|
||||
const error = err instanceof Error ? err.message : String(err)
|
||||
if (!retryable || attempt >= attempts) {
|
||||
this.log.error({ email: meta, attempt, error }, "invio email non riuscito")
|
||||
return
|
||||
}
|
||||
this.log.warn({ email: meta, attempt, error }, "invio email non riuscito, nuovo tentativo")
|
||||
await sleep(base * 2 ** (attempt - 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- Trasporti ---
|
||||
|
||||
/**
|
||||
* Sviluppo: nei log solo destinatario, template e oggetto (mai il corpo, che può contenere token).
|
||||
* Con `outboxDir` salva il messaggio completo (.txt e .html) per aprire i link in locale.
|
||||
*/
|
||||
export class ConsoleTransport implements EmailTransport {
|
||||
readonly name = "console"
|
||||
|
||||
constructor(
|
||||
private readonly log: FastifyBaseLogger,
|
||||
private readonly outboxDir?: string,
|
||||
) {}
|
||||
|
||||
async deliver(message: EmailMessage): Promise<void> {
|
||||
let file: string | undefined
|
||||
if (this.outboxDir) {
|
||||
const dir = resolve(this.outboxDir)
|
||||
await mkdir(dir, { recursive: true })
|
||||
const base = join(dir, `${new Date().toISOString().replace(/[:.]/g, "-")}-${message.tag}`)
|
||||
file = `${base}.txt`
|
||||
await writeFile(file, `To: ${message.to}\nSubject: ${message.subject}\n\n${message.text}\n`, "utf8")
|
||||
await writeFile(`${base}.html`, message.html, "utf8")
|
||||
}
|
||||
this.log.info({ email: { to: message.to, tag: message.tag, subject: message.subject, file } }, "email (console)")
|
||||
}
|
||||
}
|
||||
|
||||
type Sender = { email: string; name: string; replyTo?: string | undefined }
|
||||
type FetchFn = typeof fetch
|
||||
|
||||
/** POST JSON verso il provider con timeout; errori uniformi (EmailDeliveryError). */
|
||||
async function postJson(fetchFn: FetchFn, url: string, headers: Record<string, string>, body: unknown, provider: string) {
|
||||
let res: Response
|
||||
try {
|
||||
res = await fetchFn(url, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json", accept: "application/json", ...headers },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(10_000),
|
||||
})
|
||||
} catch (err) {
|
||||
throw new EmailDeliveryError(`${provider}: errore di rete (${err instanceof Error ? err.name : "?"})`, true)
|
||||
}
|
||||
if (!res.ok) {
|
||||
const detail = (await res.text().catch(() => "")).slice(0, 300)
|
||||
throw new EmailDeliveryError(`${provider}: HTTP ${res.status} ${detail}`, res.status === 429 || res.status >= 500)
|
||||
}
|
||||
}
|
||||
|
||||
/** Brevo: POST https://api.brevo.com/v3/smtp/email, header api-key. */
|
||||
export class BrevoTransport implements EmailTransport {
|
||||
readonly name = "brevo"
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly sender: Sender,
|
||||
private readonly fetchFn: FetchFn = fetch,
|
||||
) {}
|
||||
|
||||
deliver(m: EmailMessage) {
|
||||
return postJson(
|
||||
this.fetchFn,
|
||||
"https://api.brevo.com/v3/smtp/email",
|
||||
{ "api-key": this.apiKey },
|
||||
{
|
||||
sender: { email: this.sender.email, name: this.sender.name },
|
||||
to: [{ email: m.to }],
|
||||
subject: m.subject,
|
||||
htmlContent: m.html,
|
||||
textContent: m.text,
|
||||
tags: [m.tag],
|
||||
...(this.sender.replyTo ? { replyTo: { email: this.sender.replyTo } } : {}),
|
||||
},
|
||||
"Brevo",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Resend: POST https://api.resend.com/emails, Authorization Bearer. */
|
||||
export class ResendTransport implements EmailTransport {
|
||||
readonly name = "resend"
|
||||
|
||||
constructor(
|
||||
private readonly apiKey: string,
|
||||
private readonly sender: Sender,
|
||||
private readonly fetchFn: FetchFn = fetch,
|
||||
) {}
|
||||
|
||||
deliver(m: EmailMessage) {
|
||||
return postJson(
|
||||
this.fetchFn,
|
||||
"https://api.resend.com/emails",
|
||||
{ authorization: `Bearer ${this.apiKey}` },
|
||||
{
|
||||
from: `${this.sender.name} <${this.sender.email}>`,
|
||||
to: [m.to],
|
||||
subject: m.subject,
|
||||
html: m.html,
|
||||
text: m.text,
|
||||
// Resend: valori dei tag solo [A-Za-z0-9_-] (i nomi dei template lo rispettano)
|
||||
tags: [{ name: "template", value: m.tag }],
|
||||
...(this.sender.replyTo ? { reply_to: this.sender.replyTo } : {}),
|
||||
},
|
||||
"Resend",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** Trasporto scelto da EMAIL_PROVIDER (le chiavi richieste sono validate in config/env.ts). */
|
||||
export function createEmailTransport(config: Config, log: FastifyBaseLogger, fetchFn: FetchFn = fetch): EmailTransport {
|
||||
const sender: Sender = { email: config.EMAIL_FROM ?? "", name: config.EMAIL_FROM_NAME, replyTo: config.EMAIL_REPLY_TO }
|
||||
switch (config.EMAIL_PROVIDER) {
|
||||
case "brevo":
|
||||
return new BrevoTransport(config.BREVO_API_KEY ?? "", sender, fetchFn)
|
||||
case "resend":
|
||||
return new ResendTransport(config.RESEND_API_KEY ?? "", sender, fetchFn)
|
||||
case "console":
|
||||
return new ConsoleTransport(log, config.EMAIL_OUTBOX_DIR)
|
||||
}
|
||||
}
|
||||
|
||||
/** Adapter per i test: renderizza (stesso codice dei template) e conserva i messaggi. */
|
||||
export class MemoryEmailService implements EmailService {
|
||||
readonly sent: EmailMessage[] = []
|
||||
|
||||
async send<T extends EmailTemplate>(request: EmailRequest<T>): Promise<void> {
|
||||
this.sent.push(renderMessage(request))
|
||||
}
|
||||
|
||||
async flush(): Promise<void> {}
|
||||
|
||||
last(tag?: EmailTemplate): EmailMessage | undefined {
|
||||
return [...this.sent].reverse().find((m) => !tag || m.tag === tag)
|
||||
}
|
||||
|
||||
clear() {
|
||||
this.sent.length = 0
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||
import type { FastifyRequest } from "fastify"
|
||||
import { z } from "zod"
|
||||
import { AppError } from "../errors.js"
|
||||
|
||||
/**
|
||||
* Anti-spam dei form pubblici senza servizi terzi:
|
||||
* - honeypot: campo `website` nascosto all'utente, che i bot compilano;
|
||||
* - tempo minimo di compilazione: `formToken` emesso da GET /forms/token, firmato HMAC con l'istante di
|
||||
* emissione (il client non può falsificarlo), valido da FORM_MIN_SECONDS a FORM_TOKEN_TTL_MS;
|
||||
* - rate limit per IP sulle route (config di ciascuna route).
|
||||
* Honeypot compilato o invio troppo rapido → richiesta scartata in silenzio (risposta di successo, nulla salvato).
|
||||
*/
|
||||
|
||||
export const FORM_MIN_SECONDS = 3
|
||||
export const FORM_TOKEN_TTL_MS = 24 * 3_600_000
|
||||
|
||||
/** Campi anti-spam da aggiungere al body dei form pubblici. */
|
||||
export const formGuardFields = {
|
||||
/** Honeypot: deve restare vuoto. */
|
||||
website: z.string().optional(),
|
||||
formToken: z.string().max(200),
|
||||
}
|
||||
|
||||
const sign = (secret: string, issuedAt: number) =>
|
||||
createHmac("sha256", secret).update(`form-token:${issuedAt}`).digest("base64url")
|
||||
|
||||
export function createFormToken(secret: string, now = new Date()): { token: string; expiresAt: Date } {
|
||||
const issuedAt = now.getTime()
|
||||
return { token: `${issuedAt}.${sign(secret, issuedAt)}`, expiresAt: new Date(issuedAt + FORM_TOKEN_TTL_MS) }
|
||||
}
|
||||
|
||||
export type FormTokenCheck = "ok" | "too_fast" | "invalid" | "expired"
|
||||
|
||||
export function checkFormToken(secret: string, token: string, now = new Date()): FormTokenCheck {
|
||||
const match = /^(\d{1,15})\.([\w-]{43})$/.exec(token)
|
||||
if (!match) return "invalid"
|
||||
const issuedAt = Number(match[1])
|
||||
const expected = Buffer.from(sign(secret, issuedAt))
|
||||
const given = Buffer.from(match[2]!)
|
||||
if (expected.length !== given.length || !timingSafeEqual(expected, given)) return "invalid"
|
||||
const age = now.getTime() - issuedAt
|
||||
if (age > FORM_TOKEN_TTL_MS) return "expired"
|
||||
// Anche un token "dal futuro" (età negativa) conta come troppo rapido
|
||||
if (age < FORM_MIN_SECONDS * 1000) return "too_fast"
|
||||
return "ok"
|
||||
}
|
||||
|
||||
/**
|
||||
* Controlli anti-spam di un invio. `true` = da elaborare; `false` = spam da scartare in silenzio.
|
||||
* Token non valido o scaduto → 400 FORM_EXPIRED (il frontend chiede un nuovo token e reinvia).
|
||||
*/
|
||||
export function passesFormGuard(
|
||||
request: FastifyRequest,
|
||||
secret: string,
|
||||
body: { website?: string | undefined; formToken: string },
|
||||
form: string,
|
||||
): boolean {
|
||||
if (body.website?.trim()) {
|
||||
request.log.info({ form, reason: "honeypot" }, "invio scartato dall'anti-spam")
|
||||
return false
|
||||
}
|
||||
const check = checkFormToken(secret, body.formToken)
|
||||
if (check === "invalid" || check === "expired") {
|
||||
throw new AppError(400, "FORM_EXPIRED", "Il modulo è scaduto: ricarica la pagina e riprova")
|
||||
}
|
||||
if (check === "too_fast") {
|
||||
request.log.info({ form, reason: "too_fast" }, "invio scartato dall'anti-spam")
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { GeoPoint } from "@iti/shared"
|
||||
|
||||
/**
|
||||
* Coordinate (WGS84) del capoluogo di ogni provincia: punto di riferimento per cercare gli installatori
|
||||
* vicini a una richiesta di cui si conosce solo la provincia. Precisione ~1 km, sufficiente per
|
||||
* confrontarsi con raggi di servizio di decine di km. Stesse sigle di PROVINCES in configurator/rules.ts.
|
||||
*/
|
||||
export const PROVINCE_CENTROIDS: Record<string, GeoPoint> = {
|
||||
AL: { lat: 44.913, lng: 8.615 }, AT: { lat: 44.9, lng: 8.207 }, BI: { lat: 45.563, lng: 8.058 },
|
||||
CN: { lat: 44.384, lng: 7.543 }, NO: { lat: 45.446, lng: 8.622 }, TO: { lat: 45.07, lng: 7.687 },
|
||||
VB: { lat: 45.922, lng: 8.552 }, VC: { lat: 45.32, lng: 8.419 }, AO: { lat: 45.737, lng: 7.32 },
|
||||
BG: { lat: 45.698, lng: 9.677 }, BS: { lat: 45.541, lng: 10.212 }, CO: { lat: 45.808, lng: 9.085 },
|
||||
CR: { lat: 45.133, lng: 10.022 }, LC: { lat: 45.856, lng: 9.398 }, LO: { lat: 45.314, lng: 9.503 },
|
||||
MN: { lat: 45.156, lng: 10.791 }, MI: { lat: 45.464, lng: 9.19 }, MB: { lat: 45.585, lng: 9.274 },
|
||||
PV: { lat: 45.185, lng: 9.159 }, SO: { lat: 46.17, lng: 9.871 }, VA: { lat: 45.82, lng: 8.825 },
|
||||
BZ: { lat: 46.498, lng: 11.354 }, TN: { lat: 46.07, lng: 11.121 }, BL: { lat: 46.141, lng: 12.217 },
|
||||
PD: { lat: 45.406, lng: 11.876 }, RO: { lat: 45.07, lng: 11.79 }, TV: { lat: 45.666, lng: 12.243 },
|
||||
VE: { lat: 45.441, lng: 12.316 }, VR: { lat: 45.438, lng: 10.992 }, VI: { lat: 45.548, lng: 11.546 },
|
||||
GO: { lat: 45.941, lng: 13.622 }, PN: { lat: 45.956, lng: 12.66 }, TS: { lat: 45.65, lng: 13.777 },
|
||||
UD: { lat: 46.071, lng: 13.235 }, GE: { lat: 44.405, lng: 8.946 }, IM: { lat: 43.889, lng: 8.039 },
|
||||
SP: { lat: 44.102, lng: 9.824 }, SV: { lat: 44.309, lng: 8.477 }, BO: { lat: 44.494, lng: 11.343 },
|
||||
FE: { lat: 44.838, lng: 11.62 }, FC: { lat: 44.222, lng: 12.041 }, MO: { lat: 44.647, lng: 10.925 },
|
||||
PR: { lat: 44.801, lng: 10.328 }, PC: { lat: 45.052, lng: 9.693 }, RA: { lat: 44.418, lng: 12.204 },
|
||||
RE: { lat: 44.698, lng: 10.631 }, RN: { lat: 44.059, lng: 12.568 }, AR: { lat: 43.463, lng: 11.88 },
|
||||
FI: { lat: 43.77, lng: 11.256 }, GR: { lat: 42.763, lng: 11.113 }, LI: { lat: 43.548, lng: 10.311 },
|
||||
LU: { lat: 43.843, lng: 10.505 }, MS: { lat: 44.035, lng: 10.14 }, PI: { lat: 43.716, lng: 10.402 },
|
||||
PT: { lat: 43.933, lng: 10.917 }, PO: { lat: 43.88, lng: 11.097 }, SI: { lat: 43.318, lng: 11.331 },
|
||||
PG: { lat: 43.112, lng: 12.389 }, TR: { lat: 42.563, lng: 12.643 }, AN: { lat: 43.616, lng: 13.519 },
|
||||
AP: { lat: 42.854, lng: 13.575 }, FM: { lat: 43.161, lng: 13.718 }, MC: { lat: 43.3, lng: 13.453 },
|
||||
PU: { lat: 43.91, lng: 12.913 }, FR: { lat: 41.64, lng: 13.342 }, LT: { lat: 41.467, lng: 12.904 },
|
||||
RI: { lat: 42.404, lng: 12.857 }, RM: { lat: 41.903, lng: 12.496 }, VT: { lat: 42.42, lng: 12.107 },
|
||||
AQ: { lat: 42.35, lng: 13.4 }, CH: { lat: 42.351, lng: 14.168 }, PE: { lat: 42.465, lng: 14.214 },
|
||||
TE: { lat: 42.659, lng: 13.704 }, CB: { lat: 41.56, lng: 14.668 }, IS: { lat: 41.596, lng: 14.233 },
|
||||
AV: { lat: 40.914, lng: 14.79 }, BN: { lat: 41.13, lng: 14.782 }, CE: { lat: 41.073, lng: 14.333 },
|
||||
NA: { lat: 40.852, lng: 14.268 }, SA: { lat: 40.683, lng: 14.768 }, BA: { lat: 41.117, lng: 16.872 },
|
||||
BT: { lat: 41.227, lng: 16.295 }, BR: { lat: 40.632, lng: 17.937 }, FG: { lat: 41.462, lng: 15.544 },
|
||||
LE: { lat: 40.352, lng: 18.172 }, TA: { lat: 40.464, lng: 17.247 }, MT: { lat: 40.666, lng: 16.604 },
|
||||
PZ: { lat: 40.64, lng: 15.806 }, CZ: { lat: 38.906, lng: 16.594 }, CS: { lat: 39.298, lng: 16.254 },
|
||||
KR: { lat: 39.081, lng: 17.127 }, RC: { lat: 38.111, lng: 15.647 }, VV: { lat: 38.676, lng: 16.101 },
|
||||
AG: { lat: 37.311, lng: 13.577 }, CL: { lat: 37.49, lng: 14.062 }, CT: { lat: 37.502, lng: 15.087 },
|
||||
EN: { lat: 37.567, lng: 14.279 }, ME: { lat: 38.193, lng: 15.554 }, PA: { lat: 38.116, lng: 13.361 },
|
||||
RG: { lat: 36.926, lng: 14.731 }, SR: { lat: 37.075, lng: 15.287 }, TP: { lat: 38.017, lng: 12.537 },
|
||||
CA: { lat: 39.224, lng: 9.122 }, NU: { lat: 40.321, lng: 9.331 }, OR: { lat: 39.904, lng: 8.592 },
|
||||
SS: { lat: 40.727, lng: 8.56 }, SU: { lat: 39.168, lng: 8.522 },
|
||||
}
|
||||
|
||||
/** Riquadro dell'Italia: in @iti/shared, perché anche il selettore su mappa dell'area riservata lo usa. */
|
||||
export { isInItaly, ITALY_BOUNDS } from "@iti/shared"
|
||||
@@ -0,0 +1,71 @@
|
||||
import type { GeoPoint, InstallerAdminDto, InstallerProfileDto, NearbyInstallerDto } from "@iti/shared"
|
||||
import { and, asc, eq, isNotNull, isNull, sql } from "drizzle-orm"
|
||||
import type { DbOrTx } from "../db/client.js"
|
||||
import { type InstallerProfile, installerProfiles, type User, users } from "../db/schema/index.js"
|
||||
|
||||
export function toInstallerProfileDto(p: InstallerProfile): InstallerProfileDto {
|
||||
return {
|
||||
userId: p.userId,
|
||||
businessName: p.businessName,
|
||||
vatNumber: p.vatNumber,
|
||||
phone: p.phone,
|
||||
address: p.address,
|
||||
location: p.location,
|
||||
serviceRadiusKm: p.serviceRadiusKm,
|
||||
certifications: p.certifications,
|
||||
status: p.status,
|
||||
approvedAt: p.approvedAt?.toISOString() ?? null,
|
||||
}
|
||||
}
|
||||
|
||||
export function toInstallerAdminDto(p: InstallerProfile, u: User): InstallerAdminDto {
|
||||
return {
|
||||
...toInstallerProfileDto(p),
|
||||
email: u.email,
|
||||
firstName: u.firstName,
|
||||
lastName: u.lastName,
|
||||
notes: p.notes,
|
||||
disabled: u.disabledAt !== null,
|
||||
createdAt: p.createdAt.toISOString(),
|
||||
}
|
||||
}
|
||||
|
||||
/** Punto PostGIS (geography, WGS84) da { lat, lng }. */
|
||||
const geographyOf = (p: GeoPoint) => sql`ST_SetSRID(ST_MakePoint(${p.lng}, ${p.lat}), 4326)::geography`
|
||||
|
||||
/**
|
||||
* Installatori che coprono il punto: approvati, account attivo con ruolo installer, sede impostata e
|
||||
* `ST_DWithin(sede, punto, raggio di servizio)` (metri, su geography), ordinati per distanza dalla sede.
|
||||
* Il raggio varia per riga, quindi l'indice GIST non filtra a monte: va bene per centinaia di installatori;
|
||||
* con numeri molto maggiori aggiungere un prefiltro ST_DWithin con il raggio massimo.
|
||||
*/
|
||||
export async function findNearbyInstallers(db: DbOrTx, point: GeoPoint, limit = 20): Promise<NearbyInstallerDto[]> {
|
||||
const target = geographyOf(point)
|
||||
const distance = sql<number>`ST_Distance(${installerProfiles.location}, ${target})`
|
||||
const rows = await db
|
||||
.select({ p: installerProfiles, email: users.email, distance })
|
||||
.from(installerProfiles)
|
||||
.innerJoin(users, eq(users.id, installerProfiles.userId))
|
||||
.where(
|
||||
and(
|
||||
eq(installerProfiles.status, "approved"),
|
||||
eq(users.role, "installer"),
|
||||
isNull(users.disabledAt),
|
||||
isNotNull(installerProfiles.location),
|
||||
sql`ST_DWithin(${installerProfiles.location}, ${target}, ${installerProfiles.serviceRadiusKm} * 1000)`,
|
||||
),
|
||||
)
|
||||
.orderBy(asc(distance))
|
||||
.limit(limit)
|
||||
return rows.map(({ p, email, distance: meters }) => ({
|
||||
userId: p.userId,
|
||||
businessName: p.businessName,
|
||||
email,
|
||||
phone: p.phone,
|
||||
address: p.address,
|
||||
location: p.location!,
|
||||
serviceRadiusKm: p.serviceRadiusKm,
|
||||
certifications: p.certifications,
|
||||
distanceKm: Math.round(Number(meters) / 100) / 10,
|
||||
}))
|
||||
}
|
||||
@@ -0,0 +1,317 @@
|
||||
import { z } from "zod"
|
||||
|
||||
/**
|
||||
* Errore di una chiamata a Medusa.
|
||||
* - `unavailable`: Medusa non raggiungibile, timeout o risposta 5xx (si può ritentare più tardi).
|
||||
* - `rejected`: Medusa ha risposto 4xx (richiesta non valida, risorsa inesistente, ...).
|
||||
* - `invalid_response`: risposta 2xx con un formato inatteso.
|
||||
*/
|
||||
export class MedusaError extends Error {
|
||||
constructor(
|
||||
readonly kind: "unavailable" | "rejected" | "invalid_response",
|
||||
/** Status HTTP della risposta (assente per errori di rete/timeout). */
|
||||
readonly status: number | undefined,
|
||||
/** `type` dell'errore Medusa (es. "not_found", "invalid_data", "unauthorized"). */
|
||||
readonly type: string | undefined,
|
||||
message: string,
|
||||
) {
|
||||
super(message)
|
||||
this.name = "MedusaError"
|
||||
}
|
||||
}
|
||||
|
||||
export type FetchFn = typeof fetch
|
||||
|
||||
export type MedusaClientOptions = {
|
||||
/** Base URL del backend Medusa, senza slash finale (es. http://localhost:9000). */
|
||||
baseUrl: string
|
||||
publishableKey: string
|
||||
internalSecret: string
|
||||
/** Timeout di ogni richiesta (ms). */
|
||||
timeoutMs: number
|
||||
/** Sostituibile nei test (Medusa mockato). */
|
||||
fetch?: FetchFn
|
||||
}
|
||||
|
||||
export type RequestOptions = {
|
||||
method?: "GET" | "POST" | "DELETE"
|
||||
body?: unknown
|
||||
query?: Record<string, string | number | boolean | undefined>
|
||||
headers?: Record<string, string>
|
||||
}
|
||||
|
||||
const medusaErrorBody = z.object({ type: z.string().optional(), message: z.string().optional() }).loose()
|
||||
|
||||
/** Trasporto HTTP comune ai due client: timeout, JSON, errori uniformi (MedusaError). */
|
||||
class Transport {
|
||||
constructor(
|
||||
private readonly baseUrl: string,
|
||||
private readonly timeoutMs: number,
|
||||
private readonly fetchFn: FetchFn,
|
||||
private readonly defaultHeaders: Record<string, string>,
|
||||
) {}
|
||||
|
||||
async request<S extends z.ZodType>(path: string, schema: S, options: RequestOptions = {}): Promise<z.infer<S>> {
|
||||
const url = new URL(path, `${this.baseUrl}/`)
|
||||
for (const [k, v] of Object.entries(options.query ?? {})) {
|
||||
if (v !== undefined) url.searchParams.set(k, String(v))
|
||||
}
|
||||
const method = options.method ?? (options.body === undefined ? "GET" : "POST")
|
||||
|
||||
let res: Response
|
||||
try {
|
||||
res = await this.fetchFn(url, {
|
||||
method,
|
||||
headers: {
|
||||
accept: "application/json",
|
||||
...(options.body === undefined ? {} : { "content-type": "application/json" }),
|
||||
...this.defaultHeaders,
|
||||
...options.headers,
|
||||
},
|
||||
...(options.body === undefined ? {} : { body: JSON.stringify(options.body) }),
|
||||
signal: AbortSignal.timeout(this.timeoutMs),
|
||||
})
|
||||
} catch (err) {
|
||||
const timeout = err instanceof Error && err.name === "TimeoutError"
|
||||
throw new MedusaError(
|
||||
"unavailable",
|
||||
undefined,
|
||||
undefined,
|
||||
timeout ? `Medusa: timeout dopo ${this.timeoutMs} ms (${method} ${url.pathname})` : `Medusa non raggiungibile (${method} ${url.pathname})`,
|
||||
)
|
||||
}
|
||||
|
||||
const text = await res.text().catch(() => "")
|
||||
let json: unknown
|
||||
try {
|
||||
json = text ? JSON.parse(text) : undefined
|
||||
} catch {
|
||||
json = undefined
|
||||
}
|
||||
|
||||
if (!res.ok) {
|
||||
const parsed = medusaErrorBody.safeParse(json)
|
||||
const type = parsed.success ? parsed.data.type : undefined
|
||||
const message = (parsed.success ? parsed.data.message : undefined) ?? res.statusText
|
||||
throw new MedusaError(
|
||||
res.status >= 500 ? "unavailable" : "rejected",
|
||||
res.status,
|
||||
type,
|
||||
`Medusa ${res.status} su ${method} ${url.pathname}: ${message}`,
|
||||
)
|
||||
}
|
||||
|
||||
const parsed = schema.safeParse(json)
|
||||
if (!parsed.success) {
|
||||
throw new MedusaError(
|
||||
"invalid_response",
|
||||
res.status,
|
||||
undefined,
|
||||
`Medusa: risposta inattesa da ${method} ${url.pathname}: ${parsed.error.issues.map((i) => `${i.path.join(".")} ${i.message}`).join("; ")}`,
|
||||
)
|
||||
}
|
||||
return parsed.data
|
||||
}
|
||||
}
|
||||
|
||||
// --- Schemi delle risposte delle route interne (medusa/apps/backend/src/api/internal) ---
|
||||
|
||||
const customerSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
email: z.string(),
|
||||
first_name: z.string().nullable().optional(),
|
||||
last_name: z.string().nullable().optional(),
|
||||
company_name: z.string().nullable().optional(),
|
||||
has_account: z.boolean().optional(),
|
||||
})
|
||||
.loose()
|
||||
|
||||
export const upsertCustomerResponse = z.object({ customer: customerSchema, created: z.boolean() })
|
||||
export const setGroupResponse = z.object({
|
||||
customer_id: z.string(),
|
||||
group: z.object({ id: z.string(), name: z.string() }).loose(),
|
||||
member: z.boolean(),
|
||||
})
|
||||
export const claimOrdersResponse = z.object({
|
||||
claimed: z.array(z.string()),
|
||||
failed: z.array(z.object({ order_id: z.string(), message: z.string() })),
|
||||
})
|
||||
|
||||
const nullableString = z.string().nullable().optional()
|
||||
|
||||
const orderDetailSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
display_id: z.number(),
|
||||
status: z.string(),
|
||||
email: nullableString,
|
||||
customer_id: nullableString,
|
||||
currency_code: z.string(),
|
||||
created_at: z.string(),
|
||||
payment_status: nullableString,
|
||||
fulfillment_status: nullableString,
|
||||
item_total: z.number(),
|
||||
shipping_total: z.number(),
|
||||
tax_total: z.number(),
|
||||
discount_total: z.number().nullable().optional(),
|
||||
total: z.number(),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
title: z.string(),
|
||||
product_title: nullableString,
|
||||
variant_title: nullableString,
|
||||
variant_sku: nullableString,
|
||||
thumbnail: nullableString,
|
||||
quantity: z.number(),
|
||||
unit_price: z.number(),
|
||||
total: z.number().nullable().optional(),
|
||||
})
|
||||
.loose(),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
shipping_address: z
|
||||
.object({
|
||||
first_name: nullableString,
|
||||
last_name: nullableString,
|
||||
company: nullableString,
|
||||
address_1: nullableString,
|
||||
address_2: nullableString,
|
||||
city: nullableString,
|
||||
postal_code: nullableString,
|
||||
province: nullableString,
|
||||
country_code: nullableString,
|
||||
})
|
||||
.loose()
|
||||
.nullable()
|
||||
.optional(),
|
||||
shipping_methods: z.array(z.object({ name: nullableString }).loose()).nullable().optional(),
|
||||
fulfillments: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
labels: z
|
||||
.array(z.object({ tracking_number: nullableString, tracking_url: nullableString }).loose())
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.loose(),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.loose()
|
||||
export const orderDetailResponse = z.object({ order: orderDetailSchema })
|
||||
|
||||
export const customerOrdersResponse = z.object({
|
||||
orders: z.array(
|
||||
z
|
||||
.object({
|
||||
id: z.string(),
|
||||
display_id: z.number(),
|
||||
status: z.string(),
|
||||
created_at: z.string(),
|
||||
payment_status: z.string().nullable().optional(),
|
||||
fulfillment_status: z.string().nullable().optional(),
|
||||
item_total: z.number(),
|
||||
shipping_total: z.number(),
|
||||
tax_total: z.number(),
|
||||
total: z.number(),
|
||||
items: z
|
||||
.array(
|
||||
z
|
||||
.object({
|
||||
title: z.string(),
|
||||
variant_sku: z.string().nullable().optional(),
|
||||
thumbnail: z.string().nullable().optional(),
|
||||
quantity: z.number(),
|
||||
unit_price: z.number(),
|
||||
total: z.number().nullable().optional(),
|
||||
})
|
||||
.loose(),
|
||||
)
|
||||
.nullable()
|
||||
.optional(),
|
||||
})
|
||||
.loose(),
|
||||
),
|
||||
count: z.number(),
|
||||
offset: z.number(),
|
||||
limit: z.number(),
|
||||
})
|
||||
|
||||
export type MedusaCustomer = z.infer<typeof customerSchema>
|
||||
export type MedusaOrderDetail = z.infer<typeof orderDetailSchema>
|
||||
export type ClaimOrdersResult = z.infer<typeof claimOrdersResponse>
|
||||
|
||||
/** Route interne /internal/* (header x-internal-secret), chiamabili solo da apps/api. */
|
||||
export class MedusaInternalClient {
|
||||
constructor(private readonly http: Transport) {}
|
||||
|
||||
/** POST /internal/customers: crea o restituisce il customer "con account" per l'email (normalizzata). */
|
||||
upsertCustomer(input: { email: string; first_name: string; last_name: string; company_name?: string | null }) {
|
||||
return this.http.request("/internal/customers", upsertCustomerResponse, { body: input })
|
||||
}
|
||||
|
||||
/** POST /internal/customers/:id/groups: aggiunge/rimuove il customer dal gruppo installatori. */
|
||||
setInstallerGroup(customerId: string, action: "add" | "remove") {
|
||||
return this.http.request(`/internal/customers/${encodeURIComponent(customerId)}/groups`, setGroupResponse, {
|
||||
body: { action },
|
||||
})
|
||||
}
|
||||
|
||||
/** POST /internal/orders/claim: collega gli ordini guest con quella email. Solo con email verificata. */
|
||||
claimOrders(input: { customer_id: string; email: string }) {
|
||||
return this.http.request("/internal/orders/claim", claimOrdersResponse, { body: input })
|
||||
}
|
||||
|
||||
/** GET /internal/orders/:id: dettaglio ordine (righe, totali, indirizzo, spedizioni) per le email. */
|
||||
getOrder(orderId: string) {
|
||||
return this.http.request(`/internal/orders/${encodeURIComponent(orderId)}`, orderDetailResponse)
|
||||
}
|
||||
|
||||
/**
|
||||
* GET /internal/customers/:id/orders: ordini del customer, dal più recente (limit max 100).
|
||||
* Con `displayId` solo l'ordine con quel numero, se è del customer.
|
||||
*/
|
||||
customerOrders(customerId: string, limit: number, offset: number, displayId?: number) {
|
||||
return this.http.request(`/internal/customers/${encodeURIComponent(customerId)}/orders`, customerOrdersResponse, {
|
||||
query: { limit, offset, ...(displayId !== undefined ? { display_id: displayId } : {}) },
|
||||
})
|
||||
}
|
||||
|
||||
/** Richiesta generica verso /internal/* (per le route che arriveranno con i prossimi prompt). */
|
||||
request<S extends z.ZodType>(path: string, schema: S, options?: RequestOptions) {
|
||||
return this.http.request(path, schema, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** Store API (header x-publishable-api-key del sales channel Web). Usata dal proxy shop (prompt 13). */
|
||||
export class MedusaStoreClient {
|
||||
constructor(private readonly http: Transport) {}
|
||||
|
||||
request<S extends z.ZodType>(path: string, schema: S, options?: RequestOptions) {
|
||||
return this.http.request(path, schema, options)
|
||||
}
|
||||
}
|
||||
|
||||
/** Client Medusa: `store` (Store API) e `internal` (route /internal/*). */
|
||||
export class MedusaClient {
|
||||
readonly store: MedusaStoreClient
|
||||
readonly internal: MedusaInternalClient
|
||||
|
||||
constructor(options: MedusaClientOptions) {
|
||||
const fetchFn = options.fetch ?? globalThis.fetch
|
||||
const baseUrl = options.baseUrl.replace(/\/+$/, "")
|
||||
this.store = new MedusaStoreClient(
|
||||
new Transport(baseUrl, options.timeoutMs, fetchFn, { "x-publishable-api-key": options.publishableKey }),
|
||||
)
|
||||
this.internal = new MedusaInternalClient(
|
||||
new Transport(baseUrl, options.timeoutMs, fetchFn, { "x-internal-secret": options.internalSecret }),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import type { FastifyBaseLogger } from "fastify"
|
||||
import type { Database } from "../db/client.js"
|
||||
import { installerProfiles, type User, users } from "../db/schema/index.js"
|
||||
import { normalizeEmail } from "./auth.js"
|
||||
import { type ClaimOrdersResult, type MedusaClient, MedusaError } from "./medusa-client.js"
|
||||
|
||||
/** Dipendenze delle funzioni di sincronizzazione (in pratica `app`). */
|
||||
export type SyncDeps = { db: Database; medusa: MedusaClient; log: FastifyBaseLogger }
|
||||
|
||||
type SyncUser = Pick<User, "id" | "email" | "firstName" | "lastName" | "companyName" | "medusaCustomerId">
|
||||
|
||||
/**
|
||||
* Restituisce il customer Medusa dell'utente, creandolo se manca (POST /internal/customers, idempotente
|
||||
* per email) e salvando l'id in `users.medusa_customer_id`. Lancia MedusaError se Medusa non risponde:
|
||||
* chi non può fallire (registrazione, login) la chiama in try/catch e riprova alla volta successiva.
|
||||
*/
|
||||
export async function ensureMedusaCustomer(deps: SyncDeps, user: SyncUser): Promise<string> {
|
||||
if (user.medusaCustomerId) return user.medusaCustomerId
|
||||
|
||||
const { customer } = await deps.medusa.internal.upsertCustomer({
|
||||
email: normalizeEmail(user.email),
|
||||
first_name: user.firstName,
|
||||
last_name: user.lastName,
|
||||
company_name: user.companyName,
|
||||
})
|
||||
|
||||
// Scrive solo se ancora vuoto: due chiamate concorrenti ricevono comunque lo stesso customer (upsert per email)
|
||||
const [updated] = await deps.db
|
||||
.update(users)
|
||||
.set({ medusaCustomerId: customer.id })
|
||||
.where(and(eq(users.id, user.id), isNull(users.medusaCustomerId)))
|
||||
.returning({ medusaCustomerId: users.medusaCustomerId })
|
||||
|
||||
if (updated) {
|
||||
deps.log.info({ userId: user.id, medusaCustomerId: customer.id }, "customer Medusa collegato")
|
||||
return customer.id
|
||||
}
|
||||
const [current] = await deps.db.select({ id: users.medusaCustomerId }).from(users).where(eq(users.id, user.id))
|
||||
return current?.id ?? customer.id
|
||||
}
|
||||
|
||||
/**
|
||||
* Collega all'utente gli ordini guest fatti con la sua email (POST /internal/orders/claim).
|
||||
* Solo con email verificata: il claim lato Medusa non richiede il token inviato per email,
|
||||
* quindi la prova di possesso dell'indirizzo è responsabilità di apps/api. Idempotente.
|
||||
*/
|
||||
export async function claimGuestOrders(
|
||||
deps: SyncDeps,
|
||||
user: SyncUser & Pick<User, "emailVerifiedAt">,
|
||||
): Promise<ClaimOrdersResult | null> {
|
||||
if (!user.emailVerifiedAt) return null
|
||||
const customerId = await ensureMedusaCustomer(deps, user)
|
||||
const result = await deps.medusa.internal.claimOrders({ customer_id: customerId, email: normalizeEmail(user.email) })
|
||||
if (result.claimed.length > 0) {
|
||||
deps.log.info({ userId: user.id, orders: result.claimed }, "ordini guest collegati all'utente")
|
||||
}
|
||||
if (result.failed.length > 0) {
|
||||
deps.log.warn({ userId: user.id, failed: result.failed }, "ordini guest non collegati")
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Allinea l'appartenenza al customer group "installatori" (listino riservato) allo stato in apps/api:
|
||||
* membro solo se ruolo installer, profilo approvato e account non disabilitato.
|
||||
* Da chiamare dopo approvazione/rifiuto/revoca di un installatore (prompt 20). Lancia MedusaError.
|
||||
*/
|
||||
export async function syncInstallerGroup(
|
||||
deps: SyncDeps,
|
||||
user: SyncUser & Pick<User, "role" | "disabledAt">,
|
||||
): Promise<{ member: boolean }> {
|
||||
const [profile] = await deps.db
|
||||
.select({ status: installerProfiles.status })
|
||||
.from(installerProfiles)
|
||||
.where(eq(installerProfiles.userId, user.id))
|
||||
|
||||
const member = user.role === "installer" && profile?.status === "approved" && !user.disabledAt
|
||||
const customerId = await ensureMedusaCustomer(deps, user)
|
||||
const result = await deps.medusa.internal.setInstallerGroup(customerId, member ? "add" : "remove")
|
||||
deps.log.info({ userId: user.id, medusaCustomerId: customerId, member: result.member }, "gruppo installatori sincronizzato")
|
||||
return { member: result.member }
|
||||
}
|
||||
|
||||
/**
|
||||
* Esegue una sincronizzazione "best effort": non lancia mai, registra l'errore come warning.
|
||||
* Usata dove Medusa non deve bloccare il flusso (registrazione, verifica email, login).
|
||||
*/
|
||||
export async function trySync<T>(log: FastifyBaseLogger, what: string, userId: string, fn: () => Promise<T>): Promise<T | undefined> {
|
||||
try {
|
||||
return await fn()
|
||||
} catch (err) {
|
||||
const detail =
|
||||
err instanceof MedusaError
|
||||
? { kind: err.kind, status: err.status, type: err.type, message: err.message }
|
||||
: { message: err instanceof Error ? err.message : String(err) }
|
||||
log.warn({ userId, err: detail }, `sincronizzazione Medusa non riuscita (${what}): verrà ritentata`)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,173 @@
|
||||
import { createHmac, timingSafeEqual } from "node:crypto"
|
||||
import { eq } from "drizzle-orm"
|
||||
import type { FastifyBaseLogger } from "fastify"
|
||||
import { z } from "zod"
|
||||
import type { Config } from "../config/env.js"
|
||||
import type { Database } from "../db/client.js"
|
||||
import { webhookEvents } from "../db/schema/index.js"
|
||||
import type { EmailService } from "./email.js"
|
||||
import type { OrderEmailData } from "../emails/index.js"
|
||||
import type { MedusaClient, MedusaOrderDetail } from "./medusa-client.js"
|
||||
import { adminRecipients } from "./notifications.js"
|
||||
import { toCents } from "./shop.js"
|
||||
|
||||
/**
|
||||
* Verifica la firma dei webhook Medusa: header x-signature = HMAC-SHA256 esadecimale del body
|
||||
* grezzo con WEBHOOK_SECRET. Confronto a tempo costante tra digest della stessa lunghezza.
|
||||
*/
|
||||
export function verifyWebhookSignature(rawBody: Buffer, signature: string | undefined, secret: string): boolean {
|
||||
if (!signature || !/^[0-9a-f]{64}$/i.test(signature)) return false
|
||||
const expected = createHmac("sha256", secret).update(rawBody).digest()
|
||||
return timingSafeEqual(expected, Buffer.from(signature, "hex"))
|
||||
}
|
||||
|
||||
/** Payload inviato dal subscriber Medusa (src/subscribers/fastify-order-webhooks.ts). */
|
||||
export const medusaWebhookPayload = z.object({
|
||||
id: z.uuid(),
|
||||
event: z.string().min(1).max(200),
|
||||
occurred_at: z.string(),
|
||||
data: z.record(z.string(), z.unknown()),
|
||||
})
|
||||
export type MedusaWebhookPayload = z.infer<typeof medusaWebhookPayload>
|
||||
|
||||
export type WebhookDeps = {
|
||||
db: Database
|
||||
medusa: MedusaClient
|
||||
email: EmailService
|
||||
config: Pick<Config, "PRIVATE_APP_URL" | "ADMIN_NOTIFY_EMAILS">
|
||||
log: FastifyBaseLogger
|
||||
}
|
||||
|
||||
export type WebhookOutcome = "processed" | "ignored" | "duplicate"
|
||||
|
||||
const orderIdData = z.object({ id: z.string().min(1) })
|
||||
const fulfillmentData = z.object({
|
||||
order_id: z.string().min(1),
|
||||
fulfillment_id: z.string().min(1),
|
||||
no_notification: z.boolean().optional(),
|
||||
})
|
||||
|
||||
/** Ordine Medusa (euro) → dati dei template email (centesimi, nessun campo Medusa). */
|
||||
/** Indirizzo di spedizione in righe (nome, azienda, via, CAP città (provincia)); vuoto se manca. */
|
||||
export function shippingAddressLines(order: MedusaOrderDetail): string[] {
|
||||
const a = order.shipping_address
|
||||
return a?.address_1
|
||||
? [
|
||||
[a.first_name, a.last_name].filter(Boolean).join(" "),
|
||||
a.company ?? "",
|
||||
[a.address_1, a.address_2].filter(Boolean).join(", "),
|
||||
[a.postal_code, a.city, a.province ? `(${a.province})` : null].filter(Boolean).join(" "),
|
||||
].filter(Boolean)
|
||||
: []
|
||||
}
|
||||
|
||||
export function toOrderEmailData(order: MedusaOrderDetail): OrderEmailData {
|
||||
const a = order.shipping_address
|
||||
const shippingAddress = shippingAddressLines(order)
|
||||
return {
|
||||
number: order.display_id,
|
||||
email: order.email ?? "",
|
||||
customerName: a?.first_name?.trim() || "Cliente",
|
||||
guest: !order.customer_id,
|
||||
items: (order.items ?? []).map((i) => ({
|
||||
name: [i.product_title ?? i.title, i.variant_title].filter(Boolean).join(" - "),
|
||||
quantity: i.quantity,
|
||||
total: toCents(i.total ?? i.unit_price * i.quantity),
|
||||
})),
|
||||
itemTotal: toCents(order.item_total),
|
||||
shippingTotal: toCents(order.shipping_total),
|
||||
discountTotal: toCents(order.discount_total ?? 0),
|
||||
taxTotal: toCents(order.tax_total),
|
||||
total: toCents(order.total),
|
||||
shippingAddress,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Elaborazione per tipo di evento. Restituisce false per gli eventi non gestiti.
|
||||
* Il dettaglio ordine viene riletto da Medusa (lo snapshot del payload non ha righe e indirizzo):
|
||||
* se Medusa non risponde l'elaborazione fallisce e Medusa ritenterà.
|
||||
*/
|
||||
async function dispatch(deps: WebhookDeps, payload: MedusaWebhookPayload): Promise<boolean> {
|
||||
switch (payload.event) {
|
||||
case "order.placed": {
|
||||
const { order } = await deps.medusa.internal.getOrder(orderIdData.parse(payload.data).id)
|
||||
const data = toOrderEmailData(order)
|
||||
if (order.email) {
|
||||
await deps.email.send({
|
||||
to: order.email,
|
||||
template: "order-confirmation",
|
||||
data: { order: data, ordersUrl: `${deps.config.PRIVATE_APP_URL}/ordini` },
|
||||
})
|
||||
}
|
||||
for (const to of await adminRecipients(deps.db, deps.config)) {
|
||||
await deps.email.send({ to, template: "order-placed-admin", data: { order: data } })
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "order.canceled": {
|
||||
const { order } = await deps.medusa.internal.getOrder(orderIdData.parse(payload.data).id)
|
||||
if (order.email) {
|
||||
await deps.email.send({ to: order.email, template: "order-canceled", data: { order: toOrderEmailData(order) } })
|
||||
}
|
||||
return true
|
||||
}
|
||||
case "order.fulfillment_created": {
|
||||
const data = fulfillmentData.parse(payload.data)
|
||||
// no_notification: l'operatore ha scelto di non avvisare il cliente
|
||||
if (data.no_notification) return true
|
||||
const { order } = await deps.medusa.internal.getOrder(data.order_id)
|
||||
if (order.email) {
|
||||
const labels = order.fulfillments?.find((f) => f.id === data.fulfillment_id)?.labels ?? []
|
||||
await deps.email.send({
|
||||
to: order.email,
|
||||
template: "order-shipped",
|
||||
data: {
|
||||
order: toOrderEmailData(order),
|
||||
tracking: labels.map((l) => ({ number: l.tracking_number ?? null, url: l.tracking_url ?? null })),
|
||||
},
|
||||
})
|
||||
}
|
||||
return true
|
||||
}
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Registra ed elabora un evento in modo idempotente:
|
||||
* 1. insert in webhook_events con l'id dell'evento (già presente → nessun duplicato);
|
||||
* 2. in transazione, lock della riga (SELECT ... FOR UPDATE): consegne concorrenti dello stesso evento
|
||||
* aspettano e poi lo trovano già elaborato;
|
||||
* 3. se già elaborato → "duplicate"; altrimenti dispatch e processed_at.
|
||||
* Un errore viene salvato in webhook_events.error e rilanciato (risposta 500 → Medusa ritenta;
|
||||
* l'evento non è segnato elaborato, quindi il retry lo rielabora).
|
||||
*/
|
||||
export async function handleMedusaWebhook(deps: WebhookDeps, payload: MedusaWebhookPayload): Promise<WebhookOutcome> {
|
||||
await deps.db
|
||||
.insert(webhookEvents)
|
||||
.values({ id: payload.id, event: payload.event, payload })
|
||||
.onConflictDoNothing({ target: webhookEvents.id })
|
||||
|
||||
try {
|
||||
return await deps.db.transaction(async (tx) => {
|
||||
const [row] = await tx.select().from(webhookEvents).where(eq(webhookEvents.id, payload.id)).for("update")
|
||||
if (row?.processedAt) return "duplicate" as const
|
||||
|
||||
const handled = await dispatch(deps, payload)
|
||||
await tx
|
||||
.update(webhookEvents)
|
||||
.set({ processedAt: new Date(), error: null })
|
||||
.where(eq(webhookEvents.id, payload.id))
|
||||
return handled ? ("processed" as const) : ("ignored" as const)
|
||||
})
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err)
|
||||
await deps.db
|
||||
.update(webhookEvents)
|
||||
.set({ error: `${new Date().toISOString()} ${message}`.slice(0, 2000) })
|
||||
.where(eq(webhookEvents.id, payload.id))
|
||||
throw err
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { and, eq, isNull } from "drizzle-orm"
|
||||
import type { Config } from "../config/env.js"
|
||||
import type { StaffNotificationData } from "../emails/business.js"
|
||||
import type { EmailService } from "./email.js"
|
||||
import type { Database } from "../db/client.js"
|
||||
import { users } from "../db/schema/index.js"
|
||||
|
||||
/** Destinatari delle notifiche interne: ADMIN_NOTIFY_EMAILS, altrimenti gli utenti admin attivi. */
|
||||
export async function adminRecipients(db: Database, config: Pick<Config, "ADMIN_NOTIFY_EMAILS">): Promise<string[]> {
|
||||
if (config.ADMIN_NOTIFY_EMAILS?.length) return config.ADMIN_NOTIFY_EMAILS
|
||||
const admins = await db
|
||||
.select({ email: users.email })
|
||||
.from(users)
|
||||
.where(and(eq(users.role, "admin"), isNull(users.disabledAt)))
|
||||
return admins.map((a) => a.email)
|
||||
}
|
||||
|
||||
/** Notifica interna (template staff-notification) a tutti i destinatari admin. */
|
||||
export async function notifyStaff(
|
||||
deps: { db: Database; email: EmailService; config: Pick<Config, "ADMIN_NOTIFY_EMAILS"> },
|
||||
data: StaffNotificationData,
|
||||
): Promise<void> {
|
||||
for (const to of await adminRecipients(deps.db, deps.config)) {
|
||||
await deps.email.send({ to, template: "staff-notification", data })
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user