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
+22
View File
@@ -0,0 +1,22 @@
{
"name": "@iti/shared",
"version": "0.0.0",
"private": true,
"description": "Tipi e DTO condivisi tra apps/api, apps/public e apps/private",
"type": "module",
"main": "./dist/index.js",
"types": "./dist/index.d.ts",
"exports": {
".": {
"types": "./dist/index.d.ts",
"default": "./dist/index.js"
}
},
"files": [
"dist"
],
"scripts": {
"build": "tsc -p tsconfig.json",
"clean": "node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\""
}
}
+76
View File
@@ -0,0 +1,76 @@
/**
* Configuratore guidato (prompt 18): tipi della definizione del wizard (GET /configurator/definition)
* e del risultato (POST /configurator/evaluate | submit). Nessuna logica: le regole stanno in apps/api.
*/
export const CONFIGURATOR_PATHS = ["acqua", "fotovoltaico", "pompa_calore", "azienda"] as const
export type ConfiguratorPath = (typeof CONFIGURATOR_PATHS)[number]
/** Condizione di visibilità: tutte le condizioni di un elenco devono valere (AND). */
export interface ConfiguratorCondition {
question: string
/** Risposta (scelta singola) tra questi valori. */
in?: string[]
/** Risposta (scelta multipla) che include questo valore. */
includes?: string
}
export interface ConfiguratorOption {
value: string
label: string
help?: string
}
interface QuestionBase {
id: string
label: string
help?: string
/** Obbligatoria quando visibile. */
required: boolean
visibleIf?: ConfiguratorCondition[]
}
export type ConfiguratorQuestion =
| (QuestionBase & { type: "single"; options: ConfiguratorOption[] })
| (QuestionBase & { type: "multi"; options: ConfiguratorOption[]; minSelected: number })
| (QuestionBase & { type: "number"; unit: string; min: number; max: number; step: number })
| (QuestionBase & { type: "province" })
export interface ConfiguratorStep {
id: string
title: string
description?: string
visibleIf?: ConfiguratorCondition[]
questions: ConfiguratorQuestion[]
}
export interface ConfiguratorDefinition {
version: string
steps: ConfiguratorStep[]
/** Province per le domande di tipo "province" (sigla + nome). */
provinces: { code: string; name: string }[]
}
/** Risposte: id domanda → valore (scelta singola e provincia: string; multipla: string[]; numero: number). */
export type ConfiguratorAnswers = Record<string, string | number | string[]>
export interface ConfiguratorResult {
/** Versione delle regole usate (per riprodurre il calcolo). */
rulesVersion: string
path: ConfiguratorPath
solution: { code: string; title: string; description: string }
size: { value: number; unit: string; label: string } | null
/** Prodotti suggeriti (handle Medusa). */
products: { handle: string; quantity: number; note: string | null }[]
/** Stime in centesimi di euro (IVA inclusa); null se non calcolabili per il percorso. */
estimates: {
investmentCents: number | null
annualSavingsCents: number | null
paybackYears: number | null
}
/** Riepilogo leggibile dei calcoli (etichetta/valore). */
details: { label: string; value: string }[]
warnings: string[]
incentivesNote: string
disclaimer: string
}
+36
View File
@@ -0,0 +1,36 @@
/**
* Valori ammessi per gli stati/enumerazioni di dominio.
* Fonte unica: usati da apps/api per gli enum Postgres e dai frontend per select, filtri e badge.
*/
export const INSTALLER_STATUSES = ["pending", "approved", "rejected"] as const
export type InstallerStatus = (typeof INSTALLER_STATUSES)[number]
export const QUOTE_REQUEST_STATUSES = ["new", "contacted", "converted", "closed"] as const
export type QuoteRequestStatus = (typeof QUOTE_REQUEST_STATUSES)[number]
export const QUOTE_STATUSES = ["draft", "sent", "accepted", "rejected", "expired"] as const
export type QuoteStatus = (typeof QUOTE_STATUSES)[number]
export const TICKET_STATUSES = ["open", "in_progress", "waiting_customer", "closed"] as const
export type TicketStatus = (typeof TICKET_STATUSES)[number]
export const TICKET_PRIORITIES = ["low", "normal", "high", "urgent"] as const
export type TicketPriority = (typeof TICKET_PRIORITIES)[number]
export const BLOG_POST_STATUSES = ["draft", "published"] as const
export type BlogPostStatus = (typeof BLOG_POST_STATUSES)[number]
/** Chi può vedere/scaricare un documento: public = anche anonimi. */
export const DOCUMENT_VISIBILITIES = ["public", "customer", "installer", "admin"] as const
export type DocumentVisibility = (typeof DOCUMENT_VISIBILITIES)[number]
export const CONTACT_REQUEST_STATUSES = ["new", "in_progress", "closed"] as const
export type ContactRequestStatus = (typeof CONTACT_REQUEST_STATUSES)[number]
export const SHOWROOM_BOOKING_STATUSES = ["requested", "confirmed", "cancelled", "completed"] as const
export type ShowroomBookingStatus = (typeof SHOWROOM_BOOKING_STATUSES)[number]
/** privacy: obbligatorio per ogni form con dati personali; marketing: facoltativo e revocabile. */
export const CONSENT_TYPES = ["privacy", "marketing"] as const
export type ConsentType = (typeof CONSENT_TYPES)[number]
+445
View File
@@ -0,0 +1,445 @@
/**
* DTO pubblici: forma delle risposte JSON di apps/api consumate dai frontend.
* Date in ISO 8601 (string), importi in centesimi di euro (integer), id uuid.
* Non contengono mai campi interni (hash, note admin, path su disco, payload grezzi).
*/
import type {
BlogPostStatus,
ConsentType,
ContactRequestStatus,
DocumentVisibility,
InstallerStatus,
QuoteRequestStatus,
QuoteStatus,
ShowroomBookingStatus,
TicketPriority,
TicketStatus,
} from "./domain.js"
import type { UserRole } from "./user-role.js"
export type IsoDateTime = string
export interface UserDto {
id: string
email: string
role: UserRole
firstName: string
lastName: string
companyName: string | null
vatNumber: string | null
emailVerified: boolean
createdAt: IsoDateTime
}
/** Utente visto dall'admin (GET /admin/users): stato dell'account e collegamenti. */
export interface UserAdminDto extends UserDto {
disabledAt: IsoDateTime | null
/** True se l'utente ha un customer Medusa collegato (l'id non viene esposto). */
hasShopAccount: boolean
/** Stato del profilo installatore, se esiste. */
installerStatus: InstallerStatus | null
updatedAt: IsoDateTime
}
/** Contatori della dashboard admin (GET /admin/summary). */
export interface AdminSummaryDto {
/** Richieste di preventivo con stato `new`. */
newQuoteRequests: number
/** Ticket `open` (in attesa dell'assistenza). */
openTickets: number
/** Ticket non chiusi (qualunque stato tranne `closed`). */
activeTickets: number
/** Profili installatore `pending`. */
pendingInstallers: number
/** Prenotazioni showroom attive (`requested`/`confirmed`) da oggi in poi. */
upcomingShowroomBookings: number
/** Richieste dal form contatti con stato `new`. */
newContactRequests: number
/** Preventivi inviati in attesa di risposta del cliente. */
sentQuotes: number
}
export interface GeoPoint {
lat: number
lng: number
}
export interface InstallerProfileDto {
userId: string
businessName: string
vatNumber: string | null
phone: string | null
address: string | null
location: GeoPoint | null
serviceRadiusKm: number
certifications: string[]
status: InstallerStatus
approvedAt: IsoDateTime | null
}
/** Profilo installatore visto dall'admin: dati dell'account e note interne. */
export interface InstallerAdminDto extends InstallerProfileDto {
email: string
firstName: string
lastName: string
/** Note interne dell'admin (mai esposte all'installatore). */
notes: string | null
disabled: boolean
createdAt: IsoDateTime
}
/** Installatore che copre un punto, con la distanza dalla sua sede. */
export interface NearbyInstallerDto {
userId: string
businessName: string
email: string
phone: string | null
address: string | null
location: GeoPoint
serviceRadiusKm: number
certifications: string[]
distanceKm: number
}
export interface QuoteRequestDto {
id: string
email: string
name: string
phone: string | null
province: string | null
configuratorInput: unknown
configuratorOutput: unknown
status: QuoteRequestStatus
createdAt: IsoDateTime
}
/** Richiesta di preventivo vista dall'admin (con l'eventuale account collegato). */
export interface QuoteRequestAdminDto extends QuoteRequestDto {
userId: string | null
updatedAt: IsoDateTime
}
export interface QuoteDto {
id: string
quoteRequestId: string | null
number: string
title: string
status: QuoteStatus
amountCents: number
/** True se c'è un PDF allegato: il cliente lo scarica da GET /me/quotes/:id/pdf. */
hasPdf: boolean
/** Data (YYYY-MM-DD) oltre la quale il preventivo scade; null = senza scadenza. */
validUntil: string | null
sentAt: IsoDateTime | null
createdAt: IsoDateTime
}
/** Preventivo visto dall'admin: cliente destinatario e documento PDF. */
export interface QuoteAdminDto extends QuoteDto {
userId: string
customer: { email: string; firstName: string; lastName: string; companyName: string | null }
pdfDocumentId: string | null
}
export interface TicketDto {
id: string
subject: string
status: TicketStatus
priority: TicketPriority
createdAt: IsoDateTime
updatedAt: IsoDateTime
}
export interface TicketDetailDto extends TicketDto {
messages: TicketMessageDto[]
}
/** Ticket visto dall'admin: autore della richiesta. */
export interface TicketAdminDto extends TicketDto {
userId: string
customer: { email: string; firstName: string; lastName: string }
}
export interface TicketMessageDto {
id: string
ticketId: string
authorId: string
/** true se scritto da un admin (risposta dell'assistenza). */
fromStaff: boolean
body: string
createdAt: IsoDateTime
}
export interface BlogCategoryDto {
id: string
slug: string
name: string
description: string | null
}
export interface BlogPostSummaryDto {
id: string
slug: string
title: string
excerpt: string | null
coverUrl: string | null
category: BlogCategoryDto | null
publishedAt: IsoDateTime | null
}
export interface BlogPostDetailDto extends BlogPostSummaryDto {
contentMd: string
/** HTML renderizzato e sanitizzato lato API. */
contentHtml: string
seoTitle: string | null
seoDescription: string | null
updatedAt: IsoDateTime
}
/** Usato dall'admin: include lo stato editoriale. */
export interface BlogPostAdminDto extends BlogPostDetailDto {
status: BlogPostStatus
}
export interface DocumentDto {
id: string
title: string
category: string | null
mimeType: string
sizeBytes: number
visibility: DocumentVisibility
productRef: string | null
createdAt: IsoDateTime
}
export interface ShowroomBookingDto {
id: string
name: string
email: string
phone: string | null
requestedAt: IsoDateTime
people: number
notes: string | null
status: ShowroomBookingStatus
createdAt: IsoDateTime
}
/** Prenotazione showroom vista dall'admin: con l'utente collegato (se prenotata da autenticato). */
export interface ShowroomBookingAdminDto extends ShowroomBookingDto {
userId: string | null
updatedAt: IsoDateTime
}
/** Slot di visita allo showroom (GET /showroom/slots). */
export interface ShowroomSlotDto {
start: IsoDateTime
end: IsoDateTime
/** Posti (persone) ancora prenotabili; 0 = completo. */
available: number
}
export interface ShowroomSlotsDto {
timeZone: string
slotMinutes: number
/** Persone massime per singola prenotazione. */
maxPeoplePerBooking: number
/** Solo i giorni di apertura nel periodo richiesto; date locali YYYY-MM-DD. */
days: { date: string; slots: ShowroomSlotDto[] }[]
}
/** Richiesta dal form contatti (solo admin). */
export interface ContactRequestDto {
id: string
userId: string | null
name: string
email: string
phone: string | null
subject: string | null
message: string
status: ContactRequestStatus
createdAt: IsoDateTime
updatedAt: IsoDateTime
}
/** Token anti-spam dei form pubblici (GET /forms/token). */
export interface FormTokenDto {
token: string
/** Secondi minimi tra emissione del token e invio del form. */
minSeconds: number
expiresAt: IsoDateTime
}
export interface ConsentDto {
id: string
type: ConsentType
version: string
/** Form di provenienza (register, contact, showroom, configurator, account). */
source: string | null
acceptedAt: IsoDateTime
revokedAt: IsoDateTime | null
}
/** Ordine del cliente (da Medusa). Importi in centesimi IVA inclusa. */
export interface MyOrderDto {
/** Numero ordine leggibile (display_id Medusa). */
number: number
status: string
paymentStatus: string
fulfillmentStatus: string
createdAt: IsoDateTime
totals: { items: number; shipping: number; tax: number; total: number }
items: { title: string; sku: string | null; thumbnail: string | null; quantity: number; unitPrice: number; total: number }[]
}
/** Dettaglio di un ordine del cliente: totali con sconto, spedizione e tracking. */
export interface MyOrderDetailDto extends Omit<MyOrderDto, "totals"> {
totals: { items: number; shipping: number; discount: number; tax: number; total: number }
/** Indirizzo di spedizione già composto in righe (nome, azienda, via, CAP città (provincia)); vuoto se assente. */
shippingAddress: string[]
/** Metodi di spedizione scelti (es. "Spedizione standard"). */
shippingMethods: string[]
/** Codici di tracking delle spedizioni, se presenti. */
tracking: { number: string; url: string | null }[]
}
/** Risposta paginata standard. */
export interface Paginated<T> {
items: T[]
total: number
limit: number
offset: number
}
// --- Shop (proxy Medusa, prompt 13) ---
// Importi in centesimi di euro, IVA inclusa. Prodotti e categorie identificati dall'handle;
// gli unici id Medusa esposti sono quelli che il frontend deve rimandare (variante, riga, opzione di spedizione).
/** "installer": prezzo dal listino riservato installatori; "standard": prezzo di listino pubblico. */
export type ShopPriceType = "standard" | "installer"
export interface ShopCategoryDto {
handle: string
name: string
description: string | null
parentHandle: string | null
}
export interface ShopProductSummaryDto {
handle: string
title: string
subtitle: string | null
thumbnail: string | null
/** Prezzo più basso tra le varianti (null se nessuna variante ha un prezzo). */
fromPrice: number | null
/**
* Prezzo di listino pubblico della stessa variante di `fromPrice`: con `priceType: "installer"` è il prezzo
* da confrontare con quello riservato (listino installatori). Null se non c'è prezzo.
*/
fromOriginalPrice: number | null
priceType: ShopPriceType
categories: { handle: string; name: string }[]
}
export interface ShopVariantDto {
/** Da inviare a POST /shop/cart/items. */
id: string
title: string
sku: string | null
price: number | null
/** Prezzo di listino pubblico (diverso da price se si applica un listino riservato). */
originalPrice: number | null
inStock: boolean
options: { name: string; value: string }[]
}
export interface ShopProductDetailDto extends ShopProductSummaryDto {
description: string | null
images: string[]
variants: ShopVariantDto[]
}
export interface ShopAddressDto {
firstName: string
lastName: string
company: string | null
address1: string
address2: string | null
city: string
postalCode: string
province: string | null
countryCode: string
phone: string | null
}
/**
* Dati per la fattura a un'azienda, chiesti al checkout. Salvati nei metadata del carrello Medusa
* (`metadata.invoice`), che passano all'ordine.
*/
export interface ShopInvoiceDto {
companyName: string
/** Partita IVA italiana (11 cifre). */
vatNumber: string
/** Codice fiscale dell'azienda, se diverso dalla partita IVA. */
fiscalCode: string | null
/** Codice destinatario SDI (7 caratteri). */
sdiCode: string | null
pec: string | null
}
export interface ShopCartItemDto {
/** Id della riga: per PATCH/DELETE /shop/cart/items/:id. */
id: string
variantId: string
productHandle: string | null
productTitle: string
variantTitle: string | null
sku: string | null
thumbnail: string | null
quantity: number
unitPrice: number
total: number
}
export interface ShopCartDto {
email: string | null
items: ShopCartItemDto[]
itemCount: number
shippingAddress: ShopAddressDto | null
billingAddress: ShopAddressDto | null
/** Dati per la fattura aziendale (null = nessuna fattura richiesta). */
invoice: ShopInvoiceDto | null
shippingMethod: { optionId: string; name: string; amount: number } | null
totals: {
items: number
shipping: number
discount: number
/** IVA compresa nel totale (scorporata). */
tax: number
total: number
}
/** True se il carrello ha email, indirizzo, spedizione e almeno una riga. */
readyForPayment: boolean
}
export interface ShopShippingOptionDto {
/** Da inviare a POST /shop/cart/shipping. */
id: string
name: string
amount: number
}
export interface ShopPaymentDto {
provider: "stripe"
/** client_secret del PaymentIntent Stripe, per confirmPayment con Stripe.js. */
clientSecret: string
amount: number
}
export interface ShopOrderDto {
/** Numero ordine leggibile (display_id Medusa). */
number: number
email: string
createdAt: IsoDateTime
items: { productTitle: string; variantTitle: string | null; quantity: number; unitPrice: number; total: number }[]
totals: { items: number; shipping: number; tax: number; total: number }
}
+6
View File
@@ -0,0 +1,6 @@
export type { UserRole } from "./user-role.js"
export { USER_ROLES } from "./user-role.js"
export * from "./domain.js"
export type * from "./dto.js"
export * from "./configurator.js"
export * from "./validation.js"
+4
View File
@@ -0,0 +1,4 @@
/** Ruoli applicativi di un utente (colonna `users.role`, enum Postgres `user_role`). */
export const USER_ROLES = ["customer", "company", "installer", "admin"] as const
export type UserRole = (typeof USER_ROLES)[number]
+34
View File
@@ -0,0 +1,34 @@
// Validazioni condivise tra API e frontend (funzioni pure, nessuna dipendenza).
/** Normalizza una partita IVA italiana: senza spazi e senza prefisso "IT". */
export function normalizeVatNumber(value: string): string {
return value.replace(/\s+/g, "").toUpperCase().replace(/^IT/, "")
}
/** Partita IVA italiana valida: 11 cifre con cifra di controllo corretta (algoritmo di Luhn). */
export function isValidItalianVat(value: string): boolean {
const vat = normalizeVatNumber(value)
if (!/^\d{11}$/.test(vat)) return false
let sum = 0
for (let i = 0; i < 10; i++) {
let d = Number(vat[i])
if (i % 2 === 1) {
d *= 2
if (d > 9) d -= 9
}
sum += d
}
return (10 - (sum % 10)) % 10 === Number(vat[10])
}
/** Codice fiscale: 16 caratteri alfanumerici (persona) o 11 cifre (azienda). Controllo solo di formato. */
export const FISCAL_CODE_PATTERN = /^([A-Z0-9]{16}|\d{11})$/
/** Codice destinatario SDI: 7 caratteri alfanumerici (es. "0000000" per chi non ne ha uno). */
export const SDI_CODE_PATTERN = /^[A-Z0-9]{7}$/
/** Riquadro che contiene l'Italia (isole minori comprese): le coordinate fuori sono un errore di input. */
export const ITALY_BOUNDS = { minLat: 35.4, maxLat: 47.1, minLng: 6.6, maxLng: 18.6 } as const
export const isInItaly = (p: { lat: number; lng: number }): boolean =>
p.lat >= ITALY_BOUNDS.minLat && p.lat <= ITALY_BOUNDS.maxLat && p.lng >= ITALY_BOUNDS.minLng && p.lng <= ITALY_BOUNDS.maxLng
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": {
"rootDir": "src",
"outDir": "dist"
},
"include": ["src"]
}