Piattaforma ITI TECHNOLOGY: monorepo, Medusa, API, sito, area riservata e deploy
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:
Lorenzo Sanesi
2026-10-20 09:30:48 +02:00
co-authored by Claude Opus 5.5
commit b83ce039fb
505 changed files with 84544 additions and 0 deletions
+91
View File
@@ -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=
+50
View File
@@ -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"]
+16
View File
@@ -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,
})
+7
View File
@@ -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_..."
}
}
+147
View File
@@ -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}}
+50
View File
@@ -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"
}
}
+76
View File
@@ -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>>
+197
View File
@@ -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)
+57
View File
@@ -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,
},
},
],
},
}
}
+224
View File
@@ -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" }],
},
],
},
],
}
+400
View File
@@ -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)
}
}
+276
View File
@@ -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."
+20
View File
@@ -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 }) }
}
+25
View File
@@ -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) $$;
+205
View File
@@ -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
}
]
}
+53
View File
@@ -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"
},
})
+331
View File
@@ -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
+4
View File
@@ -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"
+90
View File
@@ -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
+50
View File
@@ -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)
}
+44
View File
@@ -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.`,
})
}
+178
View File
@@ -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.`,
})
}
+68
View File
@@ -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> = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }
/** 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))
+78
View File
@@ -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)
}
+58
View File
@@ -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`,
}
}
+122
View File
@@ -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,
})
}
+93
View File
@@ -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")
+44
View File
@@ -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",
}
+81
View File
@@ -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"] },
)
+18
View File
@@ -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" },
)
+25
View File
@@ -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"] },
)
+77
View File
@@ -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"] },
)
+47
View File
@@ -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"] },
)
+44
View File
@@ -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"] },
)
+17
View File
@@ -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"] },
)
+42
View File
@@ -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"] },
)
View File
+272
View File
@@ -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
+85
View File
@@ -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
+161
View File
@@ -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
+36
View File
@@ -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
+212
View File
@@ -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
+292
View File
@@ -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
+88
View File
@@ -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
+55
View File
@@ -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
+118
View File
@@ -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
+139
View File
@@ -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
+44
View File
@@ -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" })
}
+122
View File
@@ -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
+93
View File
@@ -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
+76
View File
@@ -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
+147
View File
@@ -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
+93
View File
@@ -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
+62
View File
@@ -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() })
+113
View File
@@ -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
+423
View File
@@ -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
+144
View File
@@ -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
+156
View File
@@ -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
+98
View File
@@ -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
+87
View File
@@ -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
+28
View File
@@ -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
+391
View File
@@ -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
+166
View File
@@ -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
+56
View File
@@ -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
+35
View File
@@ -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)
}
+178
View File
@@ -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
}
+135
View File
@@ -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,
})
+31
View File
@@ -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))
}
+85
View File
@@ -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,
}
}
+15
View File
@@ -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
}
+173
View File
@@ -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 }
}
+229
View File
@@ -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
}
}
+72
View File
@@ -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
}
+48
View File
@@ -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"
+71
View File
@@ -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,
}))
}
+317
View File
@@ -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 }),
)
}
}
+100
View File
@@ -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
}
}
+173
View File
@@ -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
}
}
+26
View File
@@ -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 })
}
}
+17
View File
@@ -0,0 +1,17 @@
import { hash, verify } from "@node-rs/argon2"
/**
* Hash delle password con argon2id (parametri di default di @node-rs/argon2:
* m=19456 KiB, t=2, p=1, raccomandati OWASP). Il risultato include salt e parametri.
*/
export function hashPassword(password: string): Promise<string> {
return hash(password)
}
export async function verifyPassword(passwordHash: string, password: string): Promise<boolean> {
try {
return await verify(passwordHash, password)
} catch {
return false
}
}
+85
View File
@@ -0,0 +1,85 @@
import type { QuoteAdminDto, QuoteDto, QuoteRequestAdminDto, QuoteStatus } from "@iti/shared"
import { and, eq, isNull, sql } from "drizzle-orm"
import type { DbOrTx } from "../db/client.js"
import { type Quote, quoteRequests, quotes, type User } from "../db/schema/index.js"
/** Namespace del lock advisory per la numerazione dei preventivi (pg_advisory_xact_lock(ns, anno)). */
const QUOTE_NUMBER_LOCK = 19_001
/** Data odierna (YYYY-MM-DD) nel fuso italiano: scadenze e anno della numerazione. */
export const todayRome = (now = new Date()) =>
new Intl.DateTimeFormat("en-CA", { timeZone: "Europe/Rome", year: "numeric", month: "2-digit", day: "2-digit" }).format(now)
/**
* Prossimo numero preventivo dell'anno: `${anno}-${seq a 4 cifre}` (es. 2026-0001).
* Va chiamata DENTRO una transazione: il lock advisory per anno serializza le creazioni concorrenti
* fino al commit, quindi max(seq)+1 non produce duplicati (il vincolo unique year+seq resta la garanzia finale).
*/
export async function nextQuoteNumber(tx: DbOrTx, year: number): Promise<{ year: number; seq: number; number: string }> {
await tx.execute(sql`select pg_advisory_xact_lock(${QUOTE_NUMBER_LOCK}, ${year})`)
const [row] = await tx
.select({ max: sql<number>`coalesce(max(${quotes.seq}), 0)::int` })
.from(quotes)
.where(eq(quotes.year, year))
const seq = (row?.max ?? 0) + 1
return { year, seq, number: `${year}-${String(seq).padStart(4, "0")}` }
}
/** Stato mostrato: un preventivo inviato oltre la data di validità è scaduto. */
export function effectiveStatus(q: Pick<Quote, "status" | "validUntil">, today = todayRome()): QuoteStatus {
return q.status === "sent" && q.validUntil !== null && q.validUntil < today ? "expired" : q.status
}
export function toQuoteDto(q: Quote): QuoteDto {
return {
id: q.id,
quoteRequestId: q.quoteRequestId,
number: q.number,
title: q.title,
status: effectiveStatus(q),
amountCents: q.amountCents,
hasPdf: q.pdfDocumentId !== null,
validUntil: q.validUntil,
sentAt: q.sentAt?.toISOString() ?? null,
createdAt: q.createdAt.toISOString(),
}
}
export function toQuoteAdminDto(q: Quote, u: Pick<User, "email" | "firstName" | "lastName" | "companyName">): QuoteAdminDto {
return {
...toQuoteDto(q),
userId: q.userId,
customer: { email: u.email, firstName: u.firstName, lastName: u.lastName, companyName: u.companyName },
pdfDocumentId: q.pdfDocumentId,
}
}
export function toQuoteRequestAdminDto(r: typeof quoteRequests.$inferSelect): QuoteRequestAdminDto {
return {
id: r.id,
userId: r.userId,
email: r.email,
name: r.name,
phone: r.phone,
province: r.province,
configuratorInput: r.configuratorInput,
configuratorOutput: r.configuratorOutput,
status: r.status,
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
}
}
/**
* Collega all'utente le richieste di preventivo anonime fatte con la sua email.
* Solo con email verificata: chi si registra con l'indirizzo di un altro non eredita le sue richieste.
*/
export async function linkAnonymousQuoteRequests(db: DbOrTx, user: Pick<User, "id" | "email" | "emailVerifiedAt">): Promise<number> {
if (!user.emailVerifiedAt) return 0
const linked = await db
.update(quoteRequests)
.set({ userId: user.id })
.where(and(isNull(quoteRequests.userId), eq(quoteRequests.email, user.email)))
.returning({ id: quoteRequests.id })
return linked.length
}
+763
View File
@@ -0,0 +1,763 @@
import type {
ShopAddressDto,
ShopCartDto,
ShopCategoryDto,
ShopInvoiceDto,
ShopOrderDto,
ShopProductDetailDto,
ShopProductSummaryDto,
ShopShippingOptionDto,
ShopVariantDto,
} from "@iti/shared"
import { eq } from "drizzle-orm"
import type { FastifyBaseLogger } from "fastify"
import { z } from "zod"
import type { Database } from "../db/client.js"
import { installerProfiles, type User, users } from "../db/schema/index.js"
import { AppError } from "../errors.js"
import { type MedusaClient, MedusaError } from "./medusa-client.js"
import { ensureMedusaCustomer } from "./medusa-sync.js"
import { TtlCache } from "./ttl-cache.js"
/** Cookie con l'id del carrello Medusa (firmato, httpOnly, path "/" così arriva anche a /auth/login). */
export const CART_COOKIE = "cart_id"
export const CART_COOKIE_MAX_AGE_S = 30 * 24 * 60 * 60
/** Importi Medusa (euro, IVA inclusa, possono avere decimali) → centesimi interi. */
export const toCents = (amount: number) => Math.round(amount * 100)
const toCentsOrNull = (amount: number | null | undefined) => (typeof amount === "number" ? toCents(amount) : null)
// --- Schemi (parziali, tolleranti) delle risposte della Store API Medusa ---
const nullableString = z.string().nullable().optional()
const calculatedPrice = z
.object({ calculated_amount: z.number().nullable(), original_amount: z.number().nullable().optional() })
.loose()
const storeVariant = z
.object({
id: z.string(),
title: nullableString,
sku: nullableString,
manage_inventory: z.boolean().nullable().optional(),
allow_backorder: z.boolean().nullable().optional(),
inventory_quantity: z.number().nullable().optional(),
calculated_price: calculatedPrice.nullable().optional(),
options: z
.array(z.object({ value: z.string(), option: z.object({ title: z.string() }).loose().nullable().optional() }).loose())
.nullable()
.optional(),
})
.loose()
const storeProduct = z
.object({
id: z.string(),
title: z.string(),
handle: z.string(),
subtitle: nullableString,
description: nullableString,
thumbnail: nullableString,
images: z.array(z.object({ url: z.string() }).loose()).nullable().optional(),
categories: z.array(z.object({ handle: z.string(), name: z.string() }).loose()).nullable().optional(),
variants: z.array(storeVariant).nullable().optional(),
})
.loose()
type StoreProduct = z.infer<typeof storeProduct>
const productList = z.object({ products: z.array(storeProduct), count: z.number() }).loose()
const storeCategory = z
.object({
id: z.string(),
handle: z.string(),
name: z.string(),
description: nullableString,
parent_category_id: nullableString,
})
.loose()
const categoryList = z.object({ product_categories: z.array(storeCategory) }).loose()
const storeAddress = 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,
phone: nullableString,
})
.loose()
const storeLineItem = z
.object({
id: z.string(),
variant_id: nullableString,
product_handle: nullableString,
product_title: nullableString,
title: z.string(),
variant_title: nullableString,
variant_sku: nullableString,
thumbnail: nullableString,
quantity: z.number(),
unit_price: z.number(),
total: z.number().nullable().optional(),
})
.loose()
const storeCart = z
.object({
id: z.string(),
email: nullableString,
customer_id: nullableString,
completed_at: nullableString,
region_id: nullableString,
item_total: z.number(),
shipping_total: z.number(),
discount_total: z.number(),
tax_total: z.number(),
total: z.number(),
items: z.array(storeLineItem).nullable().optional(),
shipping_address: storeAddress.nullable().optional(),
billing_address: storeAddress.nullable().optional(),
metadata: z.record(z.string(), z.unknown()).nullable().optional(),
shipping_methods: z
.array(
z.object({ shipping_option_id: nullableString, name: nullableString, amount: z.number() }).loose(),
)
.nullable()
.optional(),
})
.loose()
export type StoreCart = z.infer<typeof storeCart>
const cartResponse = z.object({ cart: storeCart }).loose()
const shippingOptionList = z
.object({
shipping_options: z.array(
z
.object({
id: z.string(),
name: z.string(),
amount: z.number().nullable().optional(),
insufficient_inventory: z.boolean().optional(),
})
.loose(),
),
})
.loose()
const paymentCollectionResponse = z
.object({
payment_collection: z
.object({
id: z.string(),
amount: z.number(),
payment_sessions: z
.array(
z
.object({
provider_id: z.string(),
data: z.object({ client_secret: z.string().optional() }).loose().nullable().optional(),
})
.loose(),
)
.nullable()
.optional(),
})
.loose(),
})
.loose()
const storeOrder = z
.object({
display_id: z.number(),
email: nullableString,
created_at: z.string(),
item_total: z.number(),
shipping_total: z.number(),
tax_total: z.number(),
total: z.number(),
items: z
.array(
z
.object({
title: z.string(),
product_title: nullableString,
variant_title: nullableString,
quantity: z.number(),
unit_price: z.number(),
total: z.number().nullable().optional(),
})
.loose(),
)
.nullable()
.optional(),
})
.loose()
const completeResponse = z.union([
z.object({ type: z.literal("order"), order: storeOrder }).loose(),
z
.object({
type: z.literal("cart"),
error: z.object({ message: z.string().optional(), type: z.string().optional() }).loose().optional(),
})
.loose(),
])
const regionList = z.object({
regions: z.array(
z.object({ id: z.string(), countries: z.array(z.object({ iso_2: z.string() }).loose()).nullable().optional() }).loose(),
),
})
const customerPrices = z.object({
prices: z.record(
z.string(),
z.object({
amount: z.number().nullable(),
original_amount: z.number().nullable(),
price_list_type: z.string().nullable().optional(),
}),
),
})
type CustomerPrices = z.infer<typeof customerPrices>["prices"]
/** Campi extra chiesti alla Store API per il carrello. */
const CART_FIELDS = "+items.total,+shipping_methods.name"
/** Campi extra per i prodotti: prezzo calcolato, giacenza, categorie. */
const PRODUCT_FIELDS = "*variants.calculated_price,+variants.inventory_quantity,*categories"
// --- Mappatura Medusa → DTO ---
function toAddressDto(a: z.infer<typeof storeAddress> | null | undefined): ShopAddressDto | null {
if (!a?.address_1) return null
return {
firstName: a.first_name ?? "",
lastName: a.last_name ?? "",
company: a.company ?? null,
address1: a.address_1,
address2: a.address_2 ?? null,
city: a.city ?? "",
postalCode: a.postal_code ?? "",
province: a.province ?? null,
countryCode: a.country_code ?? "it",
phone: a.phone ?? null,
}
}
const storedInvoice = z.object({
companyName: z.string(),
vatNumber: z.string(),
fiscalCode: z.string().nullable().catch(null),
sdiCode: z.string().nullable().catch(null),
pec: z.string().nullable().catch(null),
})
/** Dati di fatturazione dai metadata del carrello (null se assenti o non nel formato atteso). */
function toInvoiceDto(metadata: Record<string, unknown> | null | undefined): ShopInvoiceDto | null {
const parsed = storedInvoice.safeParse(metadata?.["invoice"])
return parsed.success ? parsed.data : null
}
export function toCartDto(cart: StoreCart): ShopCartDto {
const items = (cart.items ?? []).map((i) => ({
id: i.id,
variantId: i.variant_id ?? "",
productHandle: i.product_handle ?? null,
productTitle: i.product_title ?? i.title,
variantTitle: i.variant_title ?? null,
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),
}))
const method = cart.shipping_methods?.[0]
const shippingAddress = toAddressDto(cart.shipping_address)
const dto: ShopCartDto = {
email: cart.email ?? null,
items,
itemCount: items.reduce((n, i) => n + i.quantity, 0),
shippingAddress,
billingAddress: toAddressDto(cart.billing_address),
invoice: toInvoiceDto(cart.metadata),
shippingMethod: method
? { optionId: method.shipping_option_id ?? "", name: method.name ?? "", amount: toCents(method.amount) }
: null,
totals: {
items: toCents(cart.item_total),
shipping: toCents(cart.shipping_total),
discount: toCents(cart.discount_total),
tax: toCents(cart.tax_total),
total: toCents(cart.total),
},
readyForPayment: false,
}
dto.readyForPayment = Boolean(dto.email && shippingAddress && method && items.length > 0)
return dto
}
type PriceOverride = { amount: number | null; original: number | null; installer: boolean }
function variantPrice(v: z.infer<typeof storeVariant>, overrides: CustomerPrices | undefined): PriceOverride {
const p = v.calculated_price
const o = overrides?.[v.id]
// Con un listino "override" Medusa riporta come original_amount il prezzo riservato stesso:
// il prezzo di listino pubblico è quello standard della variante
if (o) return { amount: o.amount, original: p?.calculated_amount ?? o.original_amount, installer: o.price_list_type != null }
return { amount: p?.calculated_amount ?? null, original: p?.original_amount ?? null, installer: false }
}
function toSummary(p: StoreProduct, overrides: CustomerPrices | undefined): ShopProductSummaryDto {
let best: PriceOverride | undefined
for (const v of p.variants ?? []) {
const price = variantPrice(v, overrides)
if (price.amount !== null && (best?.amount == null || price.amount < best.amount)) best = price
}
return {
handle: p.handle,
title: p.title,
subtitle: p.subtitle ?? null,
thumbnail: p.thumbnail ?? null,
fromPrice: toCentsOrNull(best?.amount),
fromOriginalPrice: toCentsOrNull(best?.original ?? best?.amount),
priceType: best?.installer ? "installer" : "standard",
categories: (p.categories ?? []).map((c) => ({ handle: c.handle, name: c.name })),
}
}
function toDetail(p: StoreProduct, overrides: CustomerPrices | undefined): ShopProductDetailDto {
const variants: ShopVariantDto[] = (p.variants ?? []).map((v) => {
const price = variantPrice(v, overrides)
return {
id: v.id,
title: v.title ?? "",
sku: v.sku ?? null,
price: toCentsOrNull(price.amount),
originalPrice: toCentsOrNull(price.original),
inStock: v.manage_inventory === false || v.allow_backorder === true || (v.inventory_quantity ?? 0) > 0,
options: (v.options ?? []).map((o) => ({ name: o.option?.title ?? "", value: o.value })),
}
})
return {
...toSummary(p, overrides),
description: p.description ?? null,
images: (p.images ?? []).map((i) => i.url),
variants,
}
}
function toOrderDto(order: z.infer<typeof storeOrder>): ShopOrderDto {
return {
number: order.display_id,
email: order.email ?? "",
createdAt: new Date(order.created_at).toISOString(),
items: (order.items ?? []).map((i) => ({
productTitle: i.product_title ?? i.title,
variantTitle: i.variant_title ?? null,
quantity: i.quantity,
unitPrice: toCents(i.unit_price),
total: toCents(i.total ?? i.unit_price * i.quantity),
})),
totals: {
items: toCents(order.item_total),
shipping: toCents(order.shipping_total),
tax: toCents(order.tax_total),
total: toCents(order.total),
},
}
}
/**
* Traduce un rifiuto 4xx di Medusa in un errore dell'API con messaggio italiano
* (il messaggio originale, che può contenere id interni, va solo nei log).
*/
export function shopRejection(err: unknown, log: FastifyBaseLogger): never {
if (err instanceof MedusaError && err.kind === "rejected") {
log.info({ medusa: { status: err.status, type: err.type, message: err.message } }, "operazione shop rifiutata da Medusa")
if (err.status === 404) throw new AppError(404, "NOT_FOUND", "Elemento non trovato")
if (/inventory|stock/i.test(err.message)) {
throw new AppError(409, "OUT_OF_STOCK", "Quantità non disponibile a magazzino")
}
throw new AppError(400, "SHOP_INVALID_REQUEST", "Operazione non valida sul carrello")
}
throw err
}
export type ShopDeps = { db: Database; medusa: MedusaClient; log: FastifyBaseLogger }
/** Chi sta usando lo shop: anonimo, oppure utente autenticato con il suo customer Medusa. */
export type ShopViewer = { user: User; customerId: string; installer: boolean } | null
export type ProductQuery = { limit: number; offset: number; category?: string | undefined; q?: string | undefined }
/**
* Operazioni e-commerce verso Medusa: catalogo (con cache 60 s e prezzi installatore),
* carrello (proprietà, associazione al customer), spedizione, pagamento, completamento.
*/
export class ShopService {
private readonly listCache = new TtlCache<{ products: StoreProduct[]; count: number }>(60_000)
private readonly productCache = new TtlCache<StoreProduct | null>(60_000)
private readonly categoryCache = new TtlCache<z.infer<typeof storeCategory>[]>(60_000, 10)
private readonly installerPriceCache = new TtlCache<CustomerPrices>(60_000, 1000)
private regionId: string | undefined
constructor(
private readonly deps: ShopDeps,
private readonly paymentProviderId: string,
) {}
/** Svuota le cache del catalogo (test; in futuro invalidazione da webhook prodotto). */
clearCache(): void {
this.listCache.clear()
this.productCache.clear()
this.categoryCache.clear()
this.installerPriceCache.clear()
this.regionId = undefined
}
private get store() {
return this.deps.medusa.store
}
/** Regione di vendita (Italia): letta una volta dalla Store API. */
async getRegionId(): Promise<string> {
if (this.regionId) return this.regionId
const { regions } = await this.store.request("/store/regions", regionList)
const region = regions.find((r) => r.countries?.some((c) => c.iso_2 === "it")) ?? regions[0]
if (!region) throw new MedusaError("invalid_response", 200, undefined, "Medusa: nessuna regione configurata")
this.regionId = region.id
return region.id
}
// --- Utente ---
/**
* Viewer della richiesta: per un utente autenticato garantisce il customer Medusa
* (ensureMedusaCustomer) e stabilisce se è un installatore approvato (prezzi riservati).
*/
async viewerFor(user: User | undefined): Promise<ShopViewer> {
if (!user) return null
const customerId = await ensureMedusaCustomer(this.deps, user)
let installer = false
if (user.role === "installer" && !user.disabledAt) {
const [profile] = await this.deps.db
.select({ status: installerProfiles.status })
.from(installerProfiles)
.where(eq(installerProfiles.userId, user.id))
installer = profile?.status === "approved"
}
return { user, customerId, installer }
}
// --- Catalogo ---
async listCategories(): Promise<ShopCategoryDto[]> {
const categories = await this.loadCategories()
const handleById = new Map(categories.map((c) => [c.id, c.handle]))
return categories.map((c) => ({
handle: c.handle,
name: c.name,
description: c.description || null,
parentHandle: c.parent_category_id ? (handleById.get(c.parent_category_id) ?? null) : null,
}))
}
private loadCategories() {
return this.categoryCache.getOrLoad("all", async () => {
const { product_categories } = await this.store.request("/store/product-categories", categoryList, {
query: { limit: 1000, fields: "id,handle,name,description,parent_category_id" },
})
return product_categories
})
}
async listProducts(query: ProductQuery, viewer: ShopViewer) {
let categoryId: string | undefined
if (query.category) {
categoryId = (await this.loadCategories()).find((c) => c.handle === query.category)?.id
if (!categoryId) return { items: [], total: 0, limit: query.limit, offset: query.offset }
}
const regionId = await this.getRegionId()
const key = JSON.stringify([query.limit, query.offset, categoryId ?? null, query.q ?? null])
const page = await this.listCache.getOrLoad(key, async () => {
const res = await this.store.request("/store/products", productList, {
query: {
region_id: regionId,
limit: query.limit,
offset: query.offset,
fields: PRODUCT_FIELDS,
...(categoryId ? { "category_id[]": categoryId } : {}),
...(query.q ? { q: query.q } : {}),
},
})
return { products: res.products, count: res.count }
})
const overrides = await this.installerPrices(viewer, page.products)
return {
items: page.products.map((p) => toSummary(p, overrides)),
total: page.count,
limit: query.limit,
offset: query.offset,
}
}
async getProduct(handle: string, viewer: ShopViewer): Promise<ShopProductDetailDto> {
const regionId = await this.getRegionId()
const product = await this.productCache.getOrLoad(handle, async () => {
const res = await this.store.request("/store/products", productList, {
query: { region_id: regionId, handle, limit: 1, fields: PRODUCT_FIELDS },
})
return res.products[0] ?? null
})
if (!product) throw new AppError(404, "PRODUCT_NOT_FOUND", "Prodotto non trovato")
return toDetail(product, await this.installerPrices(viewer, [product]))
}
/** Prezzi del listino riservato (solo installatori approvati), calcolati da Medusa per il loro customer. */
private async installerPrices(viewer: ShopViewer, products: StoreProduct[]): Promise<CustomerPrices | undefined> {
if (!viewer?.installer) return undefined
const variantIds = products.flatMap((p) => (p.variants ?? []).map((v) => v.id)).sort()
if (variantIds.length === 0) return undefined
const regionId = await this.getRegionId()
const key = `${viewer.customerId}:${variantIds.join(",")}`
return this.installerPriceCache.getOrLoad(key, async () => {
const { prices } = await this.deps.medusa.internal.request("/internal/prices", customerPrices, {
body: { customer_id: viewer.customerId, region_id: regionId, variant_ids: variantIds },
})
return prices
})
}
// --- Carrello ---
private async fetchCart(cartId: string): Promise<StoreCart | null> {
try {
const { cart } = await this.store.request(`/store/carts/${encodeURIComponent(cartId)}`, cartResponse, {
query: { fields: CART_FIELDS },
})
return cart
} catch (err) {
if (err instanceof MedusaError && err.kind === "rejected" && err.status === 404) return null
throw err
}
}
/** True se il customer del carrello è l'account di un utente registrato (mappatura in users). */
private async ownedByAccount(customerId: string | null | undefined): Promise<boolean> {
if (!customerId) return false
const [owner] = await this.deps.db
.select({ id: users.id })
.from(users)
.where(eq(users.medusaCustomerId, customerId))
return owner !== undefined
}
/**
* Carrello utilizzabile dalla richiesta, oppure null (cookie da cancellare):
* - inesistente o già completato → null;
* - anonimo: va bene solo un carrello guest (non di un account, es. dopo un logout);
* - utente: il suo carrello, oppure un carrello guest che gli viene associato
* (transferCartCustomer: email del customer e riprezzo con i suoi listini). Il carrello
* di un altro account non è mai accessibile.
*/
async resolveCart(cartId: string | undefined, viewer: ShopViewer): Promise<StoreCart | null> {
if (!cartId) return null
const cart = await this.fetchCart(cartId)
if (!cart || cart.completed_at) return null
if (viewer && cart.customer_id === viewer.customerId) return cart
if (await this.ownedByAccount(cart.customer_id)) return null
if (!viewer) return cart
await this.deps.medusa.internal.request(`/internal/carts/${encodeURIComponent(cart.id)}/customer`, z.unknown(), {
body: { customer_id: viewer.customerId },
})
this.deps.log.info({ userId: viewer.user.id }, "carrello guest associato all'utente")
return this.fetchCart(cart.id)
}
async createCart(viewer: ShopViewer): Promise<StoreCart> {
const { cart } = await this.store.request("/store/carts", cartResponse, {
body: { region_id: await this.getRegionId() },
query: { fields: CART_FIELDS },
})
if (!viewer) return cart
await this.deps.medusa.internal.request(`/internal/carts/${encodeURIComponent(cart.id)}/customer`, z.unknown(), {
body: { customer_id: viewer.customerId },
})
return (await this.fetchCart(cart.id)) ?? cart
}
private async cartCall(path: string, options: { method?: "POST" | "DELETE"; body?: unknown }): Promise<void> {
try {
await this.store.request(path, z.unknown(), { method: options.method ?? "POST", body: options.body })
} catch (err) {
shopRejection(err, this.deps.log)
}
}
private async refetch(cartId: string): Promise<StoreCart> {
const cart = await this.fetchCart(cartId)
if (!cart) throw new AppError(404, "CART_NOT_FOUND", "Carrello non trovato")
return cart
}
async addItem(cart: StoreCart, variantId: string, quantity: number) {
await this.cartCall(`/store/carts/${cart.id}/line-items`, { body: { variant_id: variantId, quantity } })
return this.refetch(cart.id)
}
private assertItem(cart: StoreCart, itemId: string) {
if (!(cart.items ?? []).some((i) => i.id === itemId)) {
throw new AppError(404, "CART_ITEM_NOT_FOUND", "Riga del carrello non trovata")
}
}
async updateItem(cart: StoreCart, itemId: string, quantity: number) {
this.assertItem(cart, itemId)
await this.cartCall(`/store/carts/${cart.id}/line-items/${encodeURIComponent(itemId)}`, { body: { quantity } })
return this.refetch(cart.id)
}
async removeItem(cart: StoreCart, itemId: string) {
this.assertItem(cart, itemId)
await this.cartCall(`/store/carts/${cart.id}/line-items/${encodeURIComponent(itemId)}`, { method: "DELETE" })
return this.refetch(cart.id)
}
/**
* Email (solo guest) e indirizzi. Per un guest l'email passa da /internal/carts/:id/guest,
* che crea un customer guest anche se l'email appartiene a un account registrato
* (la Store API in quel caso assocerebbe il carrello all'account, con i suoi prezzi).
* Per un utente autenticato l'email è quella dell'account.
*/
async setAddress(
cart: StoreCart,
viewer: ShopViewer,
input: {
email?: string | undefined
shippingAddress: ShopAddressInput
billingAddress?: ShopAddressInput | undefined
/** Dati per la fattura aziendale; null o assente = nessuna fattura. */
invoice?: ShopInvoiceDto | null | undefined
},
) {
if (!viewer) {
if (!input.email) throw new AppError(400, "EMAIL_REQUIRED", "L'email è obbligatoria per il checkout")
if (input.email !== cart.email) {
try {
await this.deps.medusa.internal.request(`/internal/carts/${cart.id}/guest`, z.unknown(), {
body: { email: input.email },
})
} catch (err) {
shopRejection(err, this.deps.log)
}
}
}
const toMedusa = (a: ShopAddressInput) => ({
first_name: a.firstName,
last_name: a.lastName,
company: a.company ?? "",
address_1: a.address1,
address_2: a.address2 ?? "",
city: a.city,
postal_code: a.postalCode,
province: a.province ?? "",
country_code: a.countryCode,
phone: a.phone ?? "",
})
await this.cartCall(`/store/carts/${cart.id}`, {
body: {
shipping_address: toMedusa(input.shippingAddress),
billing_address: toMedusa(input.billingAddress ?? input.shippingAddress),
// Il modulo cart sostituisce i metadata: si conservano le altre chiavi. Passano all'ordine al complete.
metadata: { ...cart.metadata, invoice: input.invoice ?? null },
},
})
return this.refetch(cart.id)
}
async shippingOptions(cart: StoreCart): Promise<ShopShippingOptionDto[]> {
const { shipping_options } = await this.store.request("/store/shipping-options", shippingOptionList, {
query: { cart_id: cart.id },
})
return shipping_options
.filter((o) => !o.insufficient_inventory && typeof o.amount === "number")
.map((o) => ({ id: o.id, name: o.name, amount: toCents(o.amount as number) }))
}
async setShipping(cart: StoreCart, optionId: string) {
await this.cartCall(`/store/carts/${cart.id}/shipping-methods`, { body: { option_id: optionId } })
return this.refetch(cart.id)
}
/**
* Pagamento (sequenza Medusa v2): payment collection del carrello (creata o riusata) →
* payment session Stripe (ricreata a ogni chiamata, quindi con l'importo aggiornato) →
* client_secret del PaymentIntent per Stripe.js.
*/
async initPayment(cart: StoreCart): Promise<{ clientSecret: string; amount: number }> {
if (!toCartDto(cart).readyForPayment) {
throw new AppError(409, "CART_NOT_READY", "Completa email, indirizzo e spedizione prima del pagamento")
}
try {
const { payment_collection: collection } = await this.store.request(
"/store/payment-collections",
paymentCollectionResponse,
{ body: { cart_id: cart.id } },
)
const { payment_collection: withSession } = await this.store.request(
`/store/payment-collections/${encodeURIComponent(collection.id)}/payment-sessions`,
paymentCollectionResponse,
{ body: { provider_id: this.paymentProviderId } },
)
const session = withSession.payment_sessions?.find((s) => s.provider_id === this.paymentProviderId)
const clientSecret = session?.data?.client_secret
if (!clientSecret) {
throw new MedusaError("invalid_response", 200, undefined, "Medusa: payment session senza client_secret")
}
return { clientSecret, amount: toCents(withSession.amount) }
} catch (err) {
shopRejection(err, this.deps.log)
}
}
/** Completa il carrello dopo la conferma del pagamento lato client: restituisce l'ordine creato. */
async complete(cart: StoreCart): Promise<ShopOrderDto> {
let res: z.infer<typeof completeResponse>
try {
res = await this.store.request(`/store/carts/${cart.id}/complete`, completeResponse, { method: "POST" })
} catch (err) {
if (err instanceof MedusaError && err.kind === "rejected") {
this.deps.log.info({ medusa: { status: err.status, message: err.message } }, "completamento carrello rifiutato")
throw new AppError(402, "PAYMENT_NOT_COMPLETED", "Pagamento non completato: verifica i dati e riprova")
}
throw err
}
if (res.type !== "order") {
this.deps.log.info({ medusa: res.error }, "completamento carrello non riuscito")
throw new AppError(402, "PAYMENT_NOT_COMPLETED", "Pagamento non completato: verifica i dati e riprova")
}
return toOrderDto(res.order)
}
}
export type ShopAddressInput = {
firstName: string
lastName: string
company?: string | undefined
address1: string
address2?: string | undefined
city: string
postalCode: string
province?: string | undefined
countryCode: "it"
phone?: string | undefined
}
+78
View File
@@ -0,0 +1,78 @@
import type { ShowroomBookingAdminDto, ShowroomBookingDto } from "@iti/shared"
import { and, eq, gte, inArray, lt, ne, sql } from "drizzle-orm"
import type { Database } from "../db/client.js"
import { type ShowroomBooking, showroomBookings } from "../db/schema/index.js"
import { AppError } from "../errors.js"
type Executor = Pick<Database, "select" | "execute">
/** Stati che occupano posti nello slot. */
export const ACTIVE_BOOKING_STATUSES = ["requested", "confirmed"] as const
/** Persone prenotate per inizio slot (ms) nell'intervallo [from, to). */
export async function occupancy(db: Executor, from: Date, to: Date): Promise<Map<number, number>> {
const rows = await db
.select({ at: showroomBookings.requestedAt, people: sql<number>`sum(${showroomBookings.people})::int` })
.from(showroomBookings)
.where(
and(
gte(showroomBookings.requestedAt, from),
lt(showroomBookings.requestedAt, to),
inArray(showroomBookings.status, ACTIVE_BOOKING_STATUSES),
),
)
.groupBy(showroomBookings.requestedAt)
return new Map(rows.map((r) => [r.at.getTime(), r.people]))
}
/**
* Da chiamare dentro la transazione che crea o riattiva una prenotazione: serializza le operazioni sullo
* stesso slot (advisory lock fino al commit), poi verifica che `people` posti siano ancora liberi.
* `excludeId` esclude la prenotazione stessa (riattivazione da parte dell'admin). Slot pieno → 409 SLOT_FULL.
*/
export async function reserveSeats(
tx: Executor,
slotStart: Date,
people: number,
capacity: number,
excludeId?: string,
): Promise<void> {
// Chiave del lock: minuti dall'epoca (int4 fino al 6000 d.C.)
await tx.execute(sql`select pg_advisory_xact_lock(19002, ${Math.floor(slotStart.getTime() / 60_000)}::int)`)
const [row] = await tx
.select({ booked: sql<number>`coalesce(sum(${showroomBookings.people}), 0)::int` })
.from(showroomBookings)
.where(
and(
eq(showroomBookings.requestedAt, slotStart),
inArray(showroomBookings.status, ACTIVE_BOOKING_STATUSES),
excludeId ? ne(showroomBookings.id, excludeId) : undefined,
),
)
const free = capacity - (row?.booked ?? 0)
if (people > free) {
throw new AppError(
409,
"SLOT_FULL",
free > 0 ? `In questo orario restano ${free} posti: scegli un altro orario o riduci le persone` : "Orario al completo: scegline un altro",
)
}
}
export function toShowroomBookingDto(b: ShowroomBooking): ShowroomBookingDto {
return {
id: b.id,
name: b.name,
email: b.email,
phone: b.phone,
requestedAt: b.requestedAt.toISOString(),
people: b.people,
notes: b.notes,
status: b.status,
createdAt: b.createdAt.toISOString(),
}
}
export function toShowroomBookingAdminDto(b: ShowroomBooking): ShowroomBookingAdminDto {
return { ...toShowroomBookingDto(b), userId: b.userId, updatedAt: b.updatedAt.toISOString() }
}
+191
View File
@@ -0,0 +1,191 @@
import { randomUUID } from "node:crypto"
import { createReadStream, createWriteStream } from "node:fs"
import { mkdir, rm, stat } from "node:fs/promises"
import { dirname, extname, resolve, sep } from "node:path"
import { type Readable, Transform } from "node:stream"
import { pipeline } from "node:stream/promises"
/**
* Aree dello storage:
* - public: servita direttamente da Caddy (immagini blog, foto), URL pubblico = PUBLIC_UPLOADS_URL/<key>;
* - private: mai servita staticamente, solo in stream dall'API dopo il controllo dei permessi.
*/
export type StorageArea = "public" | "private"
/** Risultato di una scrittura: dimensione e primi byte (per riconoscere il tipo reale del file). */
export type StoredObject = { key: string; size: number; head: Buffer }
/**
* Astrazione dello storage: le route usano solo questa interfaccia, così si potrà passare a
* S3/MinIO senza toccarle. Le chiavi sono percorsi relativi generati dal server (mai dal client).
*/
export interface StorageService {
/** Scrive lo stream sotto `key`. Lancia se la chiave non è valida. */
put(area: StorageArea, key: string, stream: Readable): Promise<StoredObject>
/** Stream di lettura e dimensione; null se l'oggetto non esiste. */
get(area: StorageArea, key: string): Promise<{ stream: Readable; size: number } | null>
/** Cancella l'oggetto (nessun errore se non esiste). */
delete(area: StorageArea, key: string): Promise<void>
/** URL pubblico di un oggetto dell'area public. */
publicUrl(key: string): string
}
/** Formato delle chiavi: segmenti [a-z0-9._-], niente "..", niente percorsi assoluti. */
const KEY_RE = /^(?:[a-z0-9_-][a-z0-9._-]*\/)*[a-z0-9_-][a-z0-9._-]*$/
export class InvalidStorageKeyError extends Error {
constructor(key: string) {
super(`Chiave di storage non valida: ${JSON.stringify(key)}`)
this.name = "InvalidStorageKeyError"
}
}
/** Chiave nuova: `<prefisso>/<anno>/<mese>/<uuid><ext>` (il nome originale del file non viene mai usato). */
export function newStorageKey(prefix: string, ext: string, now = new Date()): string {
const yyyy = String(now.getUTCFullYear())
const mm = String(now.getUTCMonth() + 1).padStart(2, "0")
return `${prefix}/${yyyy}/${mm}/${randomUUID()}${ext}`
}
/** Implementazione su disco locale: `<root>/public/...` e `<root>/private/...`. */
export class LocalDiskStorage implements StorageService {
private readonly root: string
constructor(
root: string,
private readonly publicBaseUrl: string,
) {
this.root = resolve(root)
}
/** Radice di un'area (per servirla staticamente in sviluppo). */
areaRoot(area: StorageArea): string {
return resolve(this.root, area)
}
/**
* Percorso assoluto di una chiave, con doppio controllo contro il path traversal: formato della
* chiave e risultato di resolve() contenuto nella radice dell'area.
*/
private pathOf(area: StorageArea, key: string): string {
if (!KEY_RE.test(key) || key.split("/").some((s) => s === "." || s === "..")) throw new InvalidStorageKeyError(key)
const base = this.areaRoot(area)
const full = resolve(base, key)
if (!full.startsWith(base + sep)) throw new InvalidStorageKeyError(key)
return full
}
async put(area: StorageArea, key: string, stream: Readable): Promise<StoredObject> {
const path = this.pathOf(area, key)
await mkdir(dirname(path), { recursive: true })
let size = 0
const chunks: Buffer[] = []
let headLength = 0
const probe = new Transform({
transform(chunk: Buffer, _enc, cb) {
size += chunk.length
if (headLength < 64) {
chunks.push(chunk.subarray(0, 64 - headLength))
headLength += Math.min(chunk.length, 64 - headLength)
}
cb(null, chunk)
},
})
try {
await pipeline(stream, probe, createWriteStream(path, { flags: "wx" }))
} catch (err) {
await rm(path, { force: true })
throw err
}
return { key, size, head: Buffer.concat(chunks) }
}
async get(area: StorageArea, key: string) {
const path = this.pathOf(area, key)
try {
const info = await stat(path)
if (!info.isFile()) return null
return { stream: createReadStream(path), size: info.size }
} catch {
return null
}
}
async delete(area: StorageArea, key: string) {
await rm(this.pathOf(area, key), { force: true })
}
publicUrl(key: string) {
this.pathOf("public", key)
return `${this.publicBaseUrl}/${key}`
}
}
// --- Tipi di file ammessi ---
/** Tipo riconosciuto: MIME canonico salvato in DB ed estensioni accettate. */
export type FileKind = { mime: string; exts: readonly string[]; image: boolean }
export const FILE_KINDS = {
jpeg: { mime: "image/jpeg", exts: [".jpg", ".jpeg"], image: true },
png: { mime: "image/png", exts: [".png"], image: true },
webp: { mime: "image/webp", exts: [".webp"], image: true },
gif: { mime: "image/gif", exts: [".gif"], image: true },
pdf: { mime: "application/pdf", exts: [".pdf"], image: false },
zip: { mime: "application/zip", exts: [".zip"], image: false },
dwg: { mime: "image/vnd.dwg", exts: [".dwg"], image: false },
dxf: { mime: "image/vnd.dxf", exts: [".dxf"], image: false },
} as const satisfies Record<string, FileKind>
export type FileKindName = keyof typeof FILE_KINDS
/** MIME dichiarati dai browser accettati per ciascun tipo (oltre a application/octet-stream). */
const DECLARED_MIMES: Record<FileKindName, readonly string[]> = {
jpeg: ["image/jpeg", "image/pjpeg"],
png: ["image/png"],
webp: ["image/webp"],
gif: ["image/gif"],
pdf: ["application/pdf"],
zip: ["application/zip", "application/x-zip-compressed"],
dwg: ["image/vnd.dwg", "application/acad", "application/x-acad", "application/dwg", "image/x-dwg"],
dxf: ["image/vnd.dxf", "application/dxf", "image/x-dxf", "application/x-dxf"],
}
/** Riconosce il tipo reale dai primi byte (magic number). SVG e HTML non sono mai ammessi. */
export function sniffKind(head: Buffer): FileKindName | null {
const ascii = head.toString("latin1")
if (head[0] === 0xff && head[1] === 0xd8 && head[2] === 0xff) return "jpeg"
if (head.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a]))) return "png"
if (ascii.startsWith("GIF87a") || ascii.startsWith("GIF89a")) return "gif"
if (ascii.startsWith("RIFF") && ascii.slice(8, 12) === "WEBP") return "webp"
if (ascii.startsWith("%PDF-")) return "pdf"
if (ascii.startsWith("PK\x03\x04") || ascii.startsWith("PK\x05\x06")) return "zip"
if (/^AC10\d\d/.test(ascii)) return "dwg"
// DXF ASCII: gruppo "0" seguito da "SECTION" (spazi e fine riga variabili); DXF binario con firma
if (/^\s*0\r?\n\s*SECTION/.test(ascii) || /^\s*999\r?\n/.test(ascii) || ascii.startsWith("AutoCAD Binary DXF")) return "dxf"
return null
}
/**
* Valida un file caricato: estensione del nome, MIME dichiarato e contenuto devono indicare lo stesso
* tipo, tra quelli ammessi. Restituisce il tipo o un motivo di rifiuto.
*/
export function checkFileKind(
filename: string,
declaredMime: string,
head: Buffer,
allowed: readonly FileKindName[],
): { ok: true; kind: FileKindName; ext: string } | { ok: false; reason: string } {
const ext = extname(filename).toLowerCase()
const byExt = allowed.find((k) => (FILE_KINDS[k].exts as readonly string[]).includes(ext))
if (!byExt) return { ok: false, reason: `estensione non consentita (${ext || "nessuna"})` }
const declared = declaredMime.toLowerCase().split(";")[0]?.trim() ?? ""
if (declared !== "application/octet-stream" && !DECLARED_MIMES[byExt].includes(declared)) {
return { ok: false, reason: `tipo dichiarato non coerente (${declared})` }
}
const sniffed = sniffKind(head)
if (sniffed !== byExt) return { ok: false, reason: "il contenuto del file non corrisponde all'estensione" }
return { ok: true, kind: byExt, ext: FILE_KINDS[byExt].exts[0] }
}
export const DOCUMENT_KINDS: readonly FileKindName[] = ["jpeg", "png", "webp", "gif", "pdf", "zip", "dwg", "dxf"]
export const IMAGE_KINDS: readonly FileKindName[] = ["jpeg", "png", "webp", "gif"]
+41
View File
@@ -0,0 +1,41 @@
import type { TicketAdminDto, TicketDetailDto, TicketDto, TicketMessageDto } from "@iti/shared"
import { asc, eq } from "drizzle-orm"
import type { DbOrTx } from "../db/client.js"
import { type Ticket, ticketMessages, users } from "../db/schema/index.js"
export const toTicketDto = (t: Ticket): TicketDto => ({
id: t.id,
subject: t.subject,
status: t.status,
priority: t.priority,
createdAt: t.createdAt.toISOString(),
updatedAt: t.updatedAt.toISOString(),
})
export const toTicketAdminDto = (t: Ticket, u: { email: string; firstName: string; lastName: string }): TicketAdminDto => ({
...toTicketDto(t),
userId: t.userId,
customer: { email: u.email, firstName: u.firstName, lastName: u.lastName },
})
/** Messaggi in ordine cronologico; `fromStaff` se l'autore è un admin. */
export async function ticketMessagesOf(db: DbOrTx, ticketId: string): Promise<TicketMessageDto[]> {
const rows = await db
.select({ m: ticketMessages, role: users.role })
.from(ticketMessages)
.innerJoin(users, eq(users.id, ticketMessages.authorId))
.where(eq(ticketMessages.ticketId, ticketId))
.orderBy(asc(ticketMessages.createdAt), asc(ticketMessages.id))
return rows.map(({ m, role }) => ({
id: m.id,
ticketId: m.ticketId,
authorId: m.authorId,
fromStaff: role === "admin",
body: m.body,
createdAt: m.createdAt.toISOString(),
}))
}
export async function ticketDetail(db: DbOrTx, t: Ticket): Promise<TicketDetailDto> {
return { ...toTicketDto(t), messages: await ticketMessagesOf(db, t.id) }
}
+16
View File
@@ -0,0 +1,16 @@
import { createHash, randomBytes } from "node:crypto"
/**
* Token opachi (refresh, reset password, verifica email): 256 bit casuali in base64url.
* Il valore va solo al client; in DB si salva esclusivamente `hash` (SHA-256 esadecimale).
* SHA-256 senza salt basta: il token è ad alta entropia, non indovinabile come una password,
* e l'hash deterministico permette la ricerca diretta per token_hash (indice unique).
*/
export function generateToken(): { token: string; hash: string } {
const token = randomBytes(32).toString("base64url")
return { token, hash: hashToken(token) }
}
export function hashToken(token: string): string {
return createHash("sha256").update(token).digest("hex")
}
+44
View File
@@ -0,0 +1,44 @@
/**
* Cache in memoria con scadenza per voce e numero massimo di voci (le più vecchie escono per prime).
* Per dati pubblici e brevi (catalogo): ogni istanza del server ha la sua copia.
*/
export class TtlCache<V> {
private readonly entries = new Map<string, { value: V; expiresAt: number }>()
constructor(
private readonly ttlMs: number,
private readonly maxEntries = 500,
) {}
get(key: string): V | undefined {
const entry = this.entries.get(key)
if (!entry) return undefined
if (entry.expiresAt <= Date.now()) {
this.entries.delete(key)
return undefined
}
return entry.value
}
set(key: string, value: V): void {
this.entries.delete(key)
if (this.entries.size >= this.maxEntries) {
const oldest = this.entries.keys().next().value
if (oldest !== undefined) this.entries.delete(oldest)
}
this.entries.set(key, { value, expiresAt: Date.now() + this.ttlMs })
}
/** Valore in cache oppure calcolato con `load` e memorizzato (gli errori non vengono memorizzati). */
async getOrLoad(key: string, load: () => Promise<V>): Promise<V> {
const cached = this.get(key)
if (cached !== undefined) return cached
const value = await load()
this.set(key, value)
return value
}
clear(): void {
this.entries.clear()
}
}
+56
View File
@@ -0,0 +1,56 @@
/**
* Configurazione dello showroom: orari di apertura, durata delle visite, capienza.
* Cambiare orari, capienza o chiusure richiede solo di modificare questo file
* (e riallineare i casi di test in test/showroom.test.ts che dipendono dai valori).
*/
/** Fascia oraria di apertura, ore locali "HH:MM" (fine esclusa: l'ultimo slot termina entro `to`). */
export type OpeningRange = { from: string; to: string }
export type ShowroomConfig = {
timeZone: string
/** Durata di una visita (minuti): gli slot partono a intervalli di questa durata dall'inizio di ogni fascia. */
slotMinutes: number
/** Persone massime contemporaneamente in uno slot (somma delle prenotazioni attive). */
capacity: number
/** Persone massime per singola prenotazione. */
maxPeoplePerBooking: number
/** Preavviso minimo (ore) tra la prenotazione e l'inizio della visita. */
minLeadHours: number
/** Quanti giorni avanti si può prenotare. */
horizonDays: number
/** Orari per giorno ISO della settimana (1 = lunedì … 7 = domenica); giorno assente = chiuso. */
openingHours: Partial<Record<1 | 2 | 3 | 4 | 5 | 6 | 7, OpeningRange[]>>
/** Festività fisse ricorrenti "MM-DD". */
recurringClosures: string[]
/** Chiuso anche il lunedì dell'Angelo (calcolato dalla Pasqua). */
closedOnEasterMonday: boolean
/** Chiusure straordinarie o ferie "YYYY-MM-DD". */
closedDates: string[]
}
const weekday: OpeningRange[] = [
{ from: "09:00", to: "12:00" },
{ from: "14:00", to: "18:00" },
]
export const SHOWROOM: ShowroomConfig = {
timeZone: "Europe/Rome",
slotMinutes: 60,
capacity: 6,
maxPeoplePerBooking: 4,
minLeadHours: 24,
horizonDays: 60,
openingHours: {
1: weekday,
2: weekday,
3: weekday,
4: weekday,
5: weekday,
6: [{ from: "09:00", to: "12:00" }],
},
// Capodanno, Epifania, Liberazione, Lavoro, Repubblica, Ferragosto, Ognissanti, Immacolata, Natale, S. Stefano
recurringClosures: ["01-01", "01-06", "04-25", "05-01", "06-02", "08-15", "11-01", "12-08", "12-25", "12-26"],
closedOnEasterMonday: true,
closedDates: [],
}
+131
View File
@@ -0,0 +1,131 @@
import type { ShowroomConfig } from "./config.js"
/**
* Calcolo degli slot dello showroom: funzioni pure (niente DB), orari locali nel fuso della configurazione
* convertiti in istanti UTC (corretti anche nei giorni del cambio d'ora).
*/
export type Slot = { start: Date; end: Date }
const pad = (n: number) => String(n).padStart(2, "0")
/** Data locale "YYYY-MM-DD" di un istante nel fuso indicato. */
export function localDate(instant: Date, timeZone: string): string {
const parts = new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).formatToParts(instant)
const get = (type: string) => parts.find((p) => p.type === type)?.value ?? ""
return `${get("year")}-${get("month")}-${get("day")}`
}
/** Scarto (ms) del fuso rispetto a UTC nell'istante dato. */
function offsetMs(instant: Date, timeZone: string): number {
const parts = new Intl.DateTimeFormat("en-US", {
timeZone,
hourCycle: "h23",
year: "numeric",
month: "numeric",
day: "numeric",
hour: "numeric",
minute: "numeric",
second: "numeric",
}).formatToParts(instant)
const n = (type: string) => Number(parts.find((p) => p.type === type)?.value)
const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second"))
return asUtc - Math.floor(instant.getTime() / 1000) * 1000
}
/** Istante UTC dell'ora locale `time` ("HH:MM") del giorno `date` ("YYYY-MM-DD") nel fuso indicato. */
export function zonedTime(date: string, time: string, timeZone: string): Date {
const [y, m, d] = date.split("-").map(Number) as [number, number, number]
const [hh, mm] = time.split(":").map(Number) as [number, number]
const wall = Date.UTC(y, m - 1, d, hh, mm)
// Due passaggi: l'offset dipende dall'istante, che dipende dall'offset (cambio d'ora)
let utc = wall - offsetMs(new Date(wall), timeZone)
utc = wall - offsetMs(new Date(utc), timeZone)
return new Date(utc)
}
/** Aggiunge giorni a una data "YYYY-MM-DD" (calendario, senza fuso). */
export function addDays(date: string, days: number): string {
const [y, m, d] = date.split("-").map(Number) as [number, number, number]
const t = new Date(Date.UTC(y, m - 1, d + days))
return `${t.getUTCFullYear()}-${pad(t.getUTCMonth() + 1)}-${pad(t.getUTCDate())}`
}
/** Giorno ISO della settimana (1 = lunedì … 7 = domenica) di una data "YYYY-MM-DD". */
function isoWeekday(date: string): 1 | 2 | 3 | 4 | 5 | 6 | 7 {
const [y, m, d] = date.split("-").map(Number) as [number, number, number]
return (((new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7) + 1) as 1 | 2 | 3 | 4 | 5 | 6 | 7
}
/** Domenica di Pasqua (calendario gregoriano, algoritmo di Meeus/Jones/Butcher) → "YYYY-MM-DD". */
export function easterSunday(year: number): string {
const a = year % 19
const b = Math.floor(year / 100)
const c = year % 100
const d = Math.floor(b / 4)
const e = b % 4
const f = Math.floor((b + 8) / 25)
const g = Math.floor((b - f + 1) / 3)
const h = (19 * a + b - d - g + 15) % 30
const i = Math.floor(c / 4)
const k = c % 4
const l = (32 + 2 * e + 2 * i - h - k) % 7
const m = Math.floor((a + 11 * h + 22 * l) / 451)
const month = Math.floor((h + l - 7 * m + 114) / 31)
const day = ((h + l - 7 * m + 114) % 31) + 1
return `${year}-${pad(month)}-${pad(day)}`
}
export function isClosedDate(config: ShowroomConfig, date: string): boolean {
if (config.closedDates.includes(date)) return true
if (config.recurringClosures.includes(date.slice(5))) return true
if (config.closedOnEasterMonday && date === addDays(easterSunday(Number(date.slice(0, 4))), 1)) return true
return false
}
const minutesOf = (time: string) => {
const [h, m] = time.split(":").map(Number) as [number, number]
return h * 60 + m
}
/** Tutti gli slot del giorno locale `date` secondo gli orari (senza preavviso né capienza). */
export function slotsOfDay(config: ShowroomConfig, date: string): Slot[] {
if (isClosedDate(config, date)) return []
const slots: Slot[] = []
for (const range of config.openingHours[isoWeekday(date)] ?? []) {
for (let t = minutesOf(range.from); t + config.slotMinutes <= minutesOf(range.to); t += config.slotMinutes) {
const start = zonedTime(date, `${pad(Math.floor(t / 60))}:${pad(t % 60)}`, config.timeZone)
slots.push({ start, end: new Date(start.getTime() + config.slotMinutes * 60_000) })
}
}
return slots
}
/** Primo e ultimo istante prenotabili rispetto a `now` (preavviso minimo e orizzonte). */
export function bookableWindow(config: ShowroomConfig, now: Date): { from: Date; lastDate: string } {
return {
from: new Date(now.getTime() + config.minLeadHours * 3_600_000),
lastDate: addDays(localDate(now, config.timeZone), config.horizonDays),
}
}
/** Slot prenotabili (orari, chiusure, preavviso, orizzonte) dei giorni da `fromDate` per `days` giorni, raggruppati per data. */
export function bookableSlots(config: ShowroomConfig, now: Date, fromDate: string, days: number): { date: string; slots: Slot[] }[] {
const window = bookableWindow(config, now)
const result: { date: string; slots: Slot[] }[] = []
for (let i = 0; i < days; i++) {
const date = addDays(fromDate, i)
if (date > window.lastDate) break
const slots = slotsOfDay(config, date).filter((s) => s.start >= window.from)
if (slots.length) result.push({ date, slots })
}
return result
}
/** Lo slot che inizia esattamente in `start`, se prenotabile rispetto a `now`; altrimenti null. */
export function findBookableSlot(config: ShowroomConfig, now: Date, start: Date): Slot | null {
const date = localDate(start, config.timeZone)
const window = bookableWindow(config, now)
if (start < window.from || date > window.lastDate) return null
return slotsOfDay(config, date).find((s) => s.start.getTime() === start.getTime()) ?? null
}
+167
View File
@@ -0,0 +1,167 @@
import { afterAll, beforeAll, beforeEach, describe, expect, it } from "vitest"
import { buildTestApp, cookieFrom, createUserAndLogin, TEST_PASSWORD, type TestApp, truncateAll } from "./helpers.js"
let t: TestApp
beforeAll(async () => {
t = await buildTestApp()
})
afterAll(async () => {
await t.app.close()
})
beforeEach(async () => {
await truncateAll()
t.medusa.reset()
})
describe("PATCH /me", () => {
it("aggiorna nome e cognome, senza toccare il resto", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const res = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { firstName: " Mario ", lastName: "Bianchi" } })
expect(res.statusCode, res.body).toBe(200)
expect(res.json()).toMatchObject({ firstName: "Mario", lastName: "Bianchi", email: "mario@example.com", role: "customer" })
expect((await t.app.inject({ method: "GET", url: "/me", headers })).json().lastName).toBe("Bianchi")
})
it("un privato non ha dati aziendali; email e ruolo non si cambiano", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const res = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: "Mario Srl" } })
expect(res.statusCode).toBe(400)
expect(res.json().error.details[0].path).toBe("body.companyName")
const sneaky = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { role: "admin", email: "x@example.com", firstName: "M" } })
expect(sneaky.statusCode).toBe(200)
expect(sneaky.json()).toMatchObject({ role: "customer", email: "mario@example.com" })
})
it("azienda: ragione sociale e P.IVA modificabili ma non svuotabili", async () => {
const { headers } = await createUserAndLogin(t.app, "srl@example.com", "company", { companyName: "Vecchia Srl", vatNumber: "12345678903" })
const ok = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: "Nuova Srl" } })
expect(ok.json()).toMatchObject({ companyName: "Nuova Srl", vatNumber: "12345678903" })
const empty = await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { vatNumber: "" } })
expect(empty.statusCode).toBe(400)
expect(empty.json().error.details[0].path).toBe("body.vatNumber")
})
it("installatore: P.IVA facoltativa, ragione sociale obbligatoria", async () => {
const { headers } = await createUserAndLogin(t.app, "inst@example.com", "installer", { companyName: "Impianti Srl", vatNumber: "12345678903" })
expect((await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { vatNumber: null } })).json().vatNumber).toBeNull()
expect((await t.app.inject({ method: "PATCH", url: "/me", headers, payload: { companyName: " " } })).statusCode).toBe(400)
})
it("serve l'autenticazione", async () => {
expect((await t.app.inject({ method: "PATCH", url: "/me", payload: { firstName: "X" } })).statusCode).toBe(401)
})
})
describe("POST /auth/change-password", () => {
async function loginCookie(email: string, password = TEST_PASSWORD) {
const res = await t.app.inject({ method: "POST", url: "/auth/login", payload: { email, password } })
return { status: res.statusCode, cookie: cookieFrom(res.headers["set-cookie"], "iti_rt") }
}
it("con la password attuale: nuova sessione qui, le altre chiuse", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const other = await loginCookie("mario@example.com")
const res = await t.app.inject({
method: "POST",
url: "/auth/change-password",
headers,
payload: { currentPassword: TEST_PASSWORD, newPassword: "Nuova-password-123" },
})
expect(res.statusCode, res.body).toBe(200)
expect(res.json()).toMatchObject({ accessToken: expect.any(String), user: { email: "mario@example.com" } })
const fresh = cookieFrom(res.headers["set-cookie"], "iti_rt")
expect(fresh).toBeTruthy()
// l'altro dispositivo perde la sessione, questo no
const otherRefresh = await t.app.inject({ method: "POST", url: "/auth/refresh", cookies: { iti_rt: other.cookie! } })
expect(otherRefresh.statusCode).toBe(401)
const thisRefresh = await t.app.inject({ method: "POST", url: "/auth/refresh", cookies: { iti_rt: fresh! } })
expect(thisRefresh.statusCode).toBe(200)
expect((await loginCookie("mario@example.com")).status).toBe(401)
expect((await loginCookie("mario@example.com", "Nuova-password-123")).status).toBe(200)
})
it("password attuale errata → 400 sul campo, nulla cambia", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
const res = await t.app.inject({
method: "POST",
url: "/auth/change-password",
headers,
payload: { currentPassword: "sbagliata-123", newPassword: "Nuova-password-123" },
})
expect(res.statusCode).toBe(400)
expect(res.json().error).toMatchObject({ code: "INVALID_CURRENT_PASSWORD", details: [{ path: "body.currentPassword" }] })
expect((await loginCookie("mario@example.com")).status).toBe(200)
})
it("nuova password troppo corta o uguale all'attuale → 400", async () => {
const { headers } = await createUserAndLogin(t.app, "mario@example.com")
for (const newPassword of ["corta", TEST_PASSWORD]) {
const res = await t.app.inject({ method: "POST", url: "/auth/change-password", headers, payload: { currentPassword: TEST_PASSWORD, newPassword } })
expect(res.statusCode).toBe(400)
}
})
})
describe("GET /me/orders/:number", () => {
const summary = (id: string, displayId: number) => ({
id,
display_id: displayId,
status: "pending",
created_at: "2026-10-01T10:00:00.000Z",
payment_status: "captured",
fulfillment_status: "shipped",
item_total: 1190,
shipping_total: 15,
tax_total: 217.29,
total: 1205,
items: [],
})
it("dettaglio del proprio ordine con indirizzo, spedizione e tracking; 404 per gli altri", async () => {
const mario = await createUserAndLogin(t.app, "mario@example.com")
const luigi = await createUserAndLogin(t.app, "luigi@example.com")
await t.app.inject({ method: "GET", url: "/me/orders", headers: mario.headers })
const customerId = t.medusa.accountByEmail("mario@example.com")!.id
t.medusa.customerOrderList.set(customerId, [summary("order_1", 12)])
t.medusa.orderDetails.set("order_1", {
...summary("order_1", 12),
email: "mario@example.com",
customer_id: customerId,
currency_code: "eur",
discount_total: 0,
items: [
{ title: "Standard", product_title: "Addolcitore", variant_title: "Standard", variant_sku: "ADD-20", thumbnail: null, quantity: 1, unit_price: 1190, total: 1190 },
],
shipping_address: { first_name: "Mario", last_name: "Rossi", address_1: "Via Roma 1", city: "Milano", postal_code: "20100", province: "MI" },
shipping_methods: [{ name: "Spedizione standard" }],
fulfillments: [{ id: "ful_1", labels: [{ tracking_number: "BRT123", tracking_url: "https://track.example.com/BRT123" }] }],
})
const res = await t.app.inject({ method: "GET", url: "/me/orders/12", headers: mario.headers })
expect(res.statusCode, res.body).toBe(200)
expect(res.json()).toMatchObject({
number: 12,
fulfillmentStatus: "shipped",
totals: { total: 120500, shipping: 1500, discount: 0 },
items: [{ title: "Addolcitore - Standard", sku: "ADD-20", unitPrice: 119000 }],
shippingAddress: ["Mario Rossi", "Via Roma 1", "20100 Milano (MI)"],
shippingMethods: ["Spedizione standard"],
tracking: [{ number: "BRT123", url: "https://track.example.com/BRT123" }],
})
expect(res.body).not.toContain("order_1")
expect(res.body).not.toContain(customerId)
expect((await t.app.inject({ method: "GET", url: "/me/orders/12", headers: luigi.headers })).statusCode).toBe(404)
expect((await t.app.inject({ method: "GET", url: "/me/orders/99", headers: mario.headers })).statusCode).toBe(404)
expect((await t.app.inject({ method: "GET", url: "/me/orders/abc", headers: mario.headers })).statusCode).toBe(400)
})
it("Medusa giù → 503", async () => {
const mario = await createUserAndLogin(t.app, "mario@example.com")
t.medusa.down = true
expect((await t.app.inject({ method: "GET", url: "/me/orders/1", headers: mario.headers })).statusCode).toBe(503)
})
})

Some files were not shown because too many files have changed in this diff Show More