"Sogna, crea, pianifica, parti, VIVI. Il mondo è lì fuori che ti aspetta!" — così Dhonko si presenta a chi apre l'app per la prima volta.
Dhonko nasce dall'incrocio di due esigenze che di solito vivono su app separate: trovare le persone giuste con cui partire, e trovare le esperienze giuste una volta arrivati.
Da una parte c'è chi vuole viaggiare ma non ha compagni disponibili, o vuole conoscere persone compatibili prima di legarsi a un itinerario condiviso — la logica è quella di un matching per affinità, non di un semplice annuncio "cerco compagno di viaggio". Dall'altra ci sono le persone del posto — i "Local" — che conoscono davvero una destinazione e vogliono trasformare quella conoscenza in esperienze prenotabili, invece di lasciarla in una chat o in un consiglio informale.
È una compatibilità! 🎉
È il messaggio che l'app mostra quando due profili risultano affini dopo il questionario di compatibilità — la frase che, più di ogni altra, riassume il meccanismo centrale di Dhonko.
È la prima vera domanda che l'app fa dopo la registrazione. Non è retorica: determina quale delle due metà del prodotto la persona vedrà da quel momento in poi.
"Scopri nuove destinazioni, trova compagni di viaggio e vivi esperienze uniche." Crea un viaggio o si unisce a uno in costruzione, fa match con altri Dhonkers in base a preferenze e personalità, poi pianifica insieme al gruppo — spostamenti, alloggi, attività — con una road map condivisa e votata.
"Crea esperienze locali, accogli i viaggiatori e condividi la tua cultura." Pubblica attività prenotabili con orari ricorrenti, prezzi per fascia d'età e sconti gruppo, e le gestisce come un vero micro-business dentro l'app — pagamento incluso.
Email + telefono, ciascuno confermato via OTP a 6 cifre inviato in parallelo su entrambi i canali. Finché non è completa, l'account resta nello stato UNKNOWN.
16+ step: foto, biografia guidata ("è importante affinché gli altri viaggiatori decidano di partire con te"), lingue, personalità, hobby, destinazioni preferite, abitudini di viaggio.
Facoltativo (si può saltare): personalità, abitudini, tipo di viaggiatore — la base di ogni match futuro.
Viaggiatore o Local. Un Local passa per una revisione manuale (1-3 giorni lavorativi) prima di poter pubblicare esperienze.
Da qui in poi l'app diventa quotidiana: nuovi match, gruppi di viaggio da pianificare insieme, esperienze locali da prenotare o accogliere.
Ogni utente attraversa una progressione di stati man mano che completa passaggi o riceve approvazioni.
Email e telefono non ancora entrambi verificati via OTP. Accesso limitato al solo completamento della verifica.
Email e telefono confermati. Può completare profilo, questionario di compatibilità e scegliere il proprio percorso.
Crea e si unisce a viaggi, fa match, partecipa alla pianificazione condivisa, scrive recensioni.
Invia bio, dati fiscali (persona fisica o azienda, P.IVA/CF) e certificati. Un admin approva o rifiuta con motivazione; solo dopo l'approvazione può pubblicare esperienze prenotabili.
Ha una vera sezione amministrativa dentro l'app stessa: dashboard, moderazione, pagamenti — dettagli nel catalogo, gruppo 09.
Viaggi ed esperienze creati direttamente dalla piattaforma, riconoscibili dal badge "Verificato Dhonko".
Per i flussi più articolati, apri la scheda per vedere esattamente cosa mostra l'app e cosa devi compilare — presi dai form e dai DTO reali, non da supposizioni.
Una vera sezione amministrativa, non solo endpoint di backend: dashboard + 11 aree operative.
Il backend fa girare dei job periodici (NestJS Schedule) che spostano automaticamente lo stato dei viaggi in base alle date, e mandano email + notifiche push di conseguenza.
Il flusso di stato che questi job disegnano nella pratica:
Gli altri stati dell'enum — REJECTED (rifiuto admin), CANCELLED, DELETED — sono impostati da azioni manuali (rispettivamente: rifiuto in fase di revisione, cancellazione da parte del creatore, eliminazione) e non da questi scheduler.
Tre meccanismi diversi, con maturità molto diversa tra loro — vale la pena distinguerli invece di trattarli come un unico "sistema di sicurezza".
Non tutto nel catalogo sopra è ugualmente maturo. Questo si vede direttamente nel codice: alcuni flussi hanno gestione completa di caricamento/errore/successo e validazioni puntuali; altri mostrano ancora messaggi espliciti tipo "funzionalità in arrivo", o — come lo scheduler sopra — un anello mancante che si può isolare con precisione.
Preso da lib/core/constants/colors.dart e lib/app/theme/dhonko_theme.dart — gli stessi valori che Flutter compila nell'app, non una ricostruzione.
Font unico in tutta l'app: Lato (Google Fonts), nessun font secondario — nemmeno per numeri o codici.
Ricostruito 1:1 dai valori in dhonko_theme.dart — stesso raggio, stesso colore, stesso peso font dei componenti reali.
Non è Material 3 "di fabbrica" né uno stile neumorfico/glassmorfico: è un tema Material classico (usa ColorScheme, CardThemeData, ecc.) dove ogni componente è stato riscritto a mano per essere più arrotondato e più "morbido" del default — l'effetto complessivo è quello tipico delle app travel/social consumer (bottoni a pillola, input sempre bordati, card con ombra leggera). Non esiste un tema scuro: in tutto il codice è definito solo DhonkoTheme.lightTheme, nessun darkTheme collegato alla MaterialApp.


Nei mockup, la schermata di benvenuto e i tutorial mostrano sempre l'icona a goccia accompagnata dalla scritta "DHONKO" e dal claim "FEEL YOUR VIBES" in teal. L'unico asset immagine del logo nel progetto (assets/images/logo.png) contiene solo l'icona — il wordmark non esisteva da nessuna parte, quindi quelle due schermate mostravano solo il simbolo, mai il nome del brand per esteso. Aggiunto DhonkoLogoLockupWidget (icona + testo, componibile a qualunque dimensione) e collegato alla schermata di benvenuto e ai due tutorial (Traveler/Local); l'icona da sola resta invariata ovunque altro (barre superiori, ~40 punti) dove il mockup mostra solo il simbolo.




Un backend NestJS/PostgreSQL (Prisma) espone le API consumate dal client mobile Flutter; Firebase gestisce lo storage privato dei file e le notifiche push, Stripe i pagamenti delle esperienze.
Un'app Flutter unica per web e mobile (dhonko/), organizzata in tre livelli — schermate, infrastruttura condivisa, logica di dominio — con gestione dello stato a Bloc/Cubit, routing dichiarativo e 7 lingue.
Confermato flutter_bloc (Bloc + Cubit) ovunque, con freezed per gli union-state in molte feature (non tutte — alcune usano ancora classi Equatable con copyWith a mano). Convenzione di nomenclatura costante in tutto il progetto: x_bloc.dart/x_cubit.dart + state/x_state.dart + event/x_event.dart (per i Bloc), con gli stati spesso come union initial/loading/loaded (o success)/error. Anche i DTO seguono lo stesso trattamento quasi ovunque: *_dto.dart + generato *_dto.freezed.dart + *_dto.g.dart (JSON serialization).
NestJS + PostgreSQL (Prisma). Nessun prefisso globale: AdminModule e UserModule montano i propri sotto-moduli sotto /admin/<modulo> e /user/<modulo>, mentre AuthModule e TestModule dichiarano il proprio prefisso (/auth, /test). Circa 200 endpoint REST in totale, elencati qui uno per uno.
Tre sotto-moduli simili (crea, dettaglio, modifica, elimina, cambia stato, vota), uno per tipo di tappa, più le esperienze locali. Le rotte non sono uniformi tra i moduli (la modifica è PUT /:idMovement, PUT /:id/details/:idOvernight, PUT /:id/:idTodo). Regole comuni in schedule/schedule-access.ts: leggere, proporre e votare solo creatore e partecipanti confermati; modificare ed eliminare chi ha proposto o un coordinatore; cambiare stato solo un coordinatore.
Un solo gateway (ChatGateway) gestisce tutta la messaggistica live.
PostgreSQL via Prisma. Di seguito ogni modello raggruppato per dominio, con lo scopo e i campi che contano davvero per capire cosa rappresenta — non ogni singola colonna.
Ricavato leggendo .env, dipendenze (package.json/pubspec.yaml) e i file di configurazione reali dei due repository — non le dashboard di fatturazione dei fornitori, che nessun file di codice può mostrare. Dove il codice non basta a dirlo con certezza, è segnalato esplicitamente sotto.
Non un changelog di prodotto: un resoconto preciso del lavoro tecnico svolto su mock_server e su dhonko (Flutter), in ordine.
Lettura di tutto il codice sorgente di mock_server (auth, common, moduli user/admin) e produzione di un audit con criticità classificate per severità.
Corretto il bug che rendeva l'OTP sempre "000000" e quello che forzava is_verified = true al login (causa reale: mappato sul campo KYC sbagliato invece che sul completamento registrazione). L'OTP ora è casuale per davvero, con un bypass opzionale via .env (OTP_BYPASS_ENABLED), disattivato in modo incondizionato se NODE_ENV=production, come richiesto.
Chiave privata Firebase (serviceAccountKey.json) tolta dal tracking git. JWT_SECRET ruotato con un valore casuale a 384 bit. Access token accorciato da 30 giorni a 15 minuti. Creata una vera tabella di sessioni (RefreshTokenSession) con rotazione ad ogni refresh, rilevamento del riuso di un token già ruotato (revoca precauzionale di tutte le sessioni) ed endpoint /auth/logout nuovo.
Collegato il rate limiting (era configurato ma mai agganciato) con limiti stretti su login/OTP/registrazione. CORS ristretto a allow-list, aggiunto Helmet. Email e telefono non sono più visibili ad altri utenti tramite GET /user/users/:id o la lista utenti. Filtro globale che impedisce a qualunque errore di esporre stack trace o dettagli interni.
Ogni file caricato (chat, profilo, viaggi, local, documenti di verifica identità inclusi) era pubblico su Firebase Storage con un URL firmato valido fino al 2500, senza autenticazione. Creato GET /user/assets, endpoint autenticato con regole per categoria (chat e verifica privati, resto visibile a chi è loggato, admin sempre). Corretta in 5 punti di upload una vulnerabilità di path traversal (nomi file del client usati senza sanificazione in scrittura su disco). Trovate e tolte dal tracking 48 foto già committate su GitHub in tmp/photo/verify/ — confermate foto di test, non documenti reali.
Aggiunti Postgres e pgAdmin accanto al Redis già presente, per non dover più usare il database di produzione in locale. Risolti due conflitti di porta reali con servizi Windows nativi già in ascolto sulla macchina (un Postgres nativo su 5432, non Docker — causa di un'ora di autenticazioni fallite in modo random prima di essere isolata). Migration applicate e verificate sul DB locale: scoperto per l'occasione che la cronologia migration tracciata creava solo 8 tabelle su 40+, segno che il DB di produzione è stato allineato a mano con db push senza mai generare le migration corrispondenti.
Era una dipendenza installata ma mai collegata. Attivato con generazione automatica di tag e requisito di autenticazione dedotti dal path della route (nessun @ApiTags da mantenere a mano su 200+ controller). Verificato dal vivo, non solo a compile-time.
Analizzati i 4 job automatici (3 attivi ogni minuto, 1 scritto ma disattivato). Trovato un anello mancante: nessun punto del codice scrive mai lo stato SCHEDULED, quindi lo scheduler che dovrebbe far partire "ufficialmente" un viaggio non trova oggi mai nulla da processare — un viaggio approvato salta direttamente da APPROVED ad ARCHIVED allo scadere, senza mai passare da in-corso/completato. Solo analisi, non ancora corretto.
Con lo storage reso privato, ogni immagine/audio caricato dall'app doveva iniziare ad allegare l'access token o avrebbe ricevuto 401. Aggiunta una cache sincrona del token in TokenStorageService e un helper condiviso (authMediaHeaders) che allega l'header solo alle URL del nostro backend, mai a host esterni. Applicato a ogni punto reale dell'app che carica media da rete.
public/ non era servito staticamente da nessuna parte (nemmeno il file chat-test.html già presente ci era mai arrivato). Aggiunto app.useStaticAssets() in main.ts; questa pagina ora vive anche come file reale in public/overview.html, raggiungibile dal server, non solo come artifact.
Le tre voci del menu impostazioni Flutter (Privacy / Policy, Centro assistenza, Faq) aprivano tutte, letteralmente, https://www.google.com — mai completate. Scritte tre pagine reali in public/ (privacy + termini d'uso, FAQ con 12 domande sulle funzioni reali dell'app, centro assistenza con contatti per area), con lo stile del tema Flutter reale. main_menu.dart ora punta lì.
Aggiunta public/index.html come pagina di default su / (prima il root non serviva nulla). Aggiunta anche data-deletion.html: pagina pubblica separata dalla privacy policy per la richiesta di cancellazione account/dati, collegata alla funzione "Elimina account" già esistente — utile in vista di una pubblicazione reale su Google Play/App Store, che la richiedono come URL a sé stante.
Costruita in più passaggi: prima panoramica di prodotto, poi catalogo funzionalità completo dal client Flutter, poi scheduler/blocco/ban dal backend, poi identità visiva reale (logo, colori, font, sfondi) dai file del tema, poi questo registro — aggiornato di nuovo qui, come da pratica concordata: ad ogni sessione di lavoro, sia Swagger che questa pagina restano allineati a cosa esiste davvero.
Passata sistematica su tutto dhonko/lib per trovare testo non passato da easy_localization: circa 40 file tra widget, bloc/cubit e livello dati. Corretti tutti, con chiavi nuove tradotte davvero (non solo in italiano) in tutte le 7 lingue supportate (it/en/de/es/fr/ru/zh) — profilo generico e ruoli utente, pulsante segui, recupero password, avanzamento/timeout/successo verifica identità, campi di ricerca viaggio, card richieste di viaggio, pagine di successo creazione/modifica/eliminazione proposta, editor evento local (mesi del calendario ora seguono la lingua dell'app invece di essere fissi in italiano), selettore foto, stato "nessuna proposta di trasporto", calendario a intervallo.
Durante l'audit, scoperto che il bottone "Accedi" della schermata di login usava una chiave di traduzione (auth.login) che nel file JSON puntava a un oggetto annidato, non a una stringa: easy_localization non riusciva a risolverla e mostrava letteralmente il testo auth.login a schermo, non "Accedi"/"Log in". Corretto con una chiave dedicata (auth.login_button) in tutte le lingue. Trovati e corretti anche due copia-incolla: la pagina di "viaggio eliminato" mostrava il testo della pagina di recupero password ("Hai cambiato la tua Password!") con un pulsante "ACCEDI" senza senso in quel contesto.
Non solo l'interfaccia: anche i messaggi d'eccezione generati dal livello dati Flutter (chiamate API fallite, upload file, verifica OTP, permessi ban/unban, riconnessione socket) erano stringhe fisse in italiano o inglese, mostrate all'utente tramite SnackBar/stato d'errore. Unificati su un set di chiavi errors.* riutilizzabili e tradotte in tutte le 7 lingue, applicate su circa 60 punti di lancio eccezione tra repository, use-case e bloc.
Caricati in assets/figma/ 201 mockup PNG del design reale dell'app: usati come riferimento definitivo di stile per confrontare ogni schermata con quello che il codice produce davvero. Prima scoperta, la più grande: background.png — montato dietro ogni schermata di tutta l'app — era il negativo cromatico del mockup: sfondo nero con linee teal spesse a piena opacità, invece di sfondo bianco con linee teal pallidissime. Rigenerata l'immagine dall'arte vettoriale sorgente in due varianti corrette (chiara per l'app, blu navy per le schermate a piena pagina con testo bianco) — vedi dettagli nella sezione 09 "Identità visiva", ora aggiornata di conseguenza. Trovato nello stesso punto un secondo bug reale: DhonkoScaffoldBlue montava lo sfondo chiaro invece di quello scuro dedicato, rendendo a rischio illeggibilità il testo bianco su ~18 schermate (verifica identità, completamento profilo, travel match, pagine di successo viaggio/proposta). Aggiunto anche il lockup di logo (icona + "DHONKO" + "FEEL YOUR VIBES") mancante su benvenuto e tutorial. Il resto dei 201 mockup (form di viaggio, pianificazione condivisa, home, profilo) è stato campionato ma non ancora passato in rassegna schermata per schermata.
Riaudit completo di tutto dhonko/lib per stringhe hardcoded residue. Trovato il caso più grave della sessione: profile_setup_steps.dart — lo step che genera le 14 schermate di preferenze mostrate a ogni singolo nuovo utente durante la registrazione (stato civile, lingue, destinazioni, hobby, personalità, compagni di viaggio, animali, abitudini, vita notturna, alloggio, alimentazione, tipo di viaggiatore, mezzi di trasporto, itinerari) — passava titoli e sottotitoli fissi in italiano invece di richiamare .tr(), nonostante le chiavi corrette (auth.profile_setup.steps.*) esistessero già, tradotte in tutte le 7 lingue, semplicemente mai collegate. Un utente non italiano vedeva quindi metà della registrazione in italiano puro. Collegate tutte le 28 chiavi mancanti. Corretti inoltre: un'etichetta "Rispondi a" in chat, i motivi di rifiuto della verifica identità, i messaggi d'errore del recupero password e del caricamento profilo (~15 varianti), il nome di fallback delle chat senza nome, un messaggio di errore offline in chat, il valore di default della biografia utente (mai traducibile perché hardcoded in un parametro costante — sostituito con stringa vuota), e 7 messaggi di conferma nel pannello admin (creazione/modifica/eliminazione contenuti Gate, eliminazione utente).
Test in web (finestra larga) hanno mostrato il problema vero: lo sfondo era un'unica immagine bitmap in formato verticale-telefono, forzata a coprire finestre larghissime con BoxFit.cover — il pattern veniva ingrandito a dismisura, le linee diventavano rade e sfocate, quasi illeggibili. Trovato il file sorgente reale (assets/svg/background.svg, 430×932, linee #00C1B3 al 10% di opacità): rigenerata l'immagine direttamente da quel vettoriale (non più una ricostruzione approssimata) e cambiato il meccanismo da "un'immagine stirata" a un tassello piastrellato alla sua scala reale (DecorationImage con ImageRepeat.repeat), così la densità del pattern resta identica indipendentemente dalla larghezza della finestra — desktop, web o telefono che sia.
Il layout a due colonne per schermi larghi (≥1200px) aveva due problemi: l'illustrazione usava BoxFit.cover e veniva tagliata male (testa/piedi fuori inquadratura), e il blocco di testo a sinistra era centrato dentro una colonna allineata a sinistra — un disallineamento visivo tipico dei layout "riadattati" male. Illustrazione ora in contain dentro un pannello con alone sfumato teal/arancio coerente col brand; titolo/sottotitolo/CTA allineati a sinistra su desktop, centrati su mobile/tablet come da mockup.
Creato un design-kit condiviso in lib/app/pages/admin/widgets/ (AdminPageScaffold, AdminSectionCard, AdminKpiCard, AdminEntityCard, AdminStatusBadge, AdminSearchField, AdminFilterChips, stati vuoti/errore, dialog di conferma) invece di ridisegnare ogni schermata a mano. Bug strutturale trovato su quasi tutte le pagine admin: ognuna aveva la propria Scaffold+AppBar, mostrata IN PIÙ rispetto a quella già presente nella shell di AdminHomePage quando usate come tab — due barre sovrapposte. Risolto con una modalità embedded nello scaffold condiviso. Dashboard principale ricostruita da zero (griglia vera su desktop, colonna singola su mobile, nuova sezione "Azioni rapide"). Restyle delle 13 schermate rimanenti con lo stesso stile piatto/bordato e badge di stato coerenti. Aggiunte 15 chiavi di traduzione mancanti in tutte le 7 lingue.
Secondo design-kit condiviso, estratto dallo stile della pagina Viaggi presa come riferimento esplicito: admin_list_kit.dart (bottoni azione uniformi, striscia KPI, tab a pillola, toolbar ricerca+filtri+ordinamento, tabella con intestazioni ordinabili, paginazione) e admin_detail_kit.dart (sezioni di dettaglio, righe etichetta/valore, chip informativi, tile utente collegabile al profilo, tile record correlato con bottone "Dettagli", viewer JSON, galleria foto con zoom). La pagina "Esperienze Local" è stata rifatta completamente sul modello Viaggi, sia lista che dettaglio (prima il dettaglio mostrava solo titolo/destinazione/stato/gestore — ora galleria foto, tutti i campi pubblicati, logistica, regole di prenotazione, host con profilo operatore completo, ogni ricorrenza con le sue occorrenze generate). Il Log Attività copriva solo 10 azioni su 32 possibili e 5 target su 10 — ricostruito con mappatura completa, dialog di dettaglio per ogni voce con JSON grezzo ispezionabile. Bug trovato di passaggio: un campo competenze dichiarato come lista in un DTO nuovo, ma sul backend è testo libero — crash TypeError non appena un profilo Local aveva quel campo compilato. Bug di traduzione preesistente (non introdotto qui) anche trovato: admin.sidebar.*/admin.topbar.* esistevano solo in italiano e inglese.
Bug sistemico in due varianti su tutto il pannello admin: (1) molte pagine mostravano solo le iniziali senza nemmeno provare a caricare la foto, pur avendo l'URL disponibile; (2) le poche che ci provavano usavano NetworkImage/CircleAvatar.backgroundImage senza il Bearer token richiesto dal nostro endpoint autenticato /user/assets — la richiesta tornava 401 e, siccome il fallback iniziali veniva passato solo quando la foto era assente (non quando falliva), l'avatar restava vuoto del tutto. Creato AdminAvatar, un solo widget con header di autenticazione + fallback iniziali vero via errorBuilder (non onBackgroundImageError, che non riesce a ripristinare un fallback in modo affidabile), sostituito in tutte le 7 sezioni segnalate più altre 3 trovate con lo stesso bug. Bug di backend trovato in coppia: l'endpoint /admin/users non selezionava affatto il campo photo nella query Prisma.
Rinominato lo scheduling utente in "Roadmap" (richiesto esplicitamente). Backend: l'endpoint /admin/travels/:id non includeva affatto chi avesse proposto ogni spostamento/pernottamento/attività, né i voti dei partecipanti, né chi avesse salvato il viaggio — tutti aggiunti all'include Prisma. Frontend: la vecchia sezione "Itinerario" (tipo/data/prezzo in una riga piatta) rifatta come sezione "Roadmap del viaggio" con icona per tipo specifico, stato di conferma, chi l'ha proposta (link al profilo), e il conteggio dei voti pro/contro con fila di avatar cliccabili. Aggiunte due sezioni nuove: "Salvato da" (chip con avatar+data) e "Preferenze di matching" (i campi che alimentano i suggerimenti di compatibilità, con etichette leggibili). Bug trovato di passaggio, non collegato al restyling: il bottone "Trova compagni di viaggio", mostrato dall'app a qualsiasi coordinatore, veniva rifiutato dal backend con un 400 perché autorizzava solo il creatore esatto del viaggio — corretto in entrambi gli use-case coinvolti.
Sostituito showTimePicker (l'orologio Material con lancetta da trascinare, giudicato scomodo) con un nuovo showDhonkoTimePicker — una rondella scorrevole ore:minuti in stile bottom sheet coerente col brand — su tutti e 4 i punti in cui si sceglie un orario: creazione/modifica proposta di spostamento e di attività (i pernottamenti hanno solo una data, mai avuto un orario).
Creato SettingsSectionScaffold: un'unica intestazione (bottone indietro + titolo centrato) e corpo scrollabile condiviso da tutte le schermate del bottom sheet impostazioni. Tre bug trovati e corretti: (1) il bottom sheet non aveva altezza vincolata — la sezione "Viaggi salvati" usava Expanded dentro una Column senza altezza definita, rischio di crash reale a runtime; (2) SettingsTile aveva l'icona commentata (//leading: icon) — nessuna icona è mai stata visibile in nessun menu impostazioni; (3) "Verifica Account" veniva renderizzato dentro il bottom sheet come uno Scaffold annidato senza alcun bottone indietro. La navigazione da singola stringa _selectedSection (con salti sbagliati per due sezioni) è diventata un vero stack push/pop — ogni sezione torna indietro esattamente da dove si è entrati. "Informazioni personali" mostrava tre campi sempre vuoti (mai valorizzati) — ora popolati dai dati reali. Privacy/Help/FAQ spostate dal menu principale dentro Impostazioni, insieme alla nuova sezione Lingua (le 7 lingue già tradotte, bandiera+nome nativo, cambio immediato).
Bug di backend importante: le 13 leve "Informazioni visibili nel profilo" (UserPrefsSettings) venivano salvate correttamente ma non erano mai lette — GET /user/users/:id restituiva sempre tutti i campi a chiunque, indipendentemente dalle preferenze. Corretto in users-get-by-id.use-case.ts. Aggiunte 3 leve mancanti dall'interfaccia (itineraries, travelUserType, travelType). TabUserProfileWidget riscritto nello stesso stile a card del dettaglio viaggio, con un interruttore occhio/occhio-barrato per campo (solo per il proprietario) che nasconde/mostra il dato agli altri all'istante. Corpo della pagina trasformato in vere tab stile Instagram (SliverPersistentHeader+TabBar, pattern già esistente in LocalProfile ma mai attivato per il Traveler — c'era perfino il codice commentato che lo preannunciava): Info, Viaggi, Esperienze, più Salvati visibile solo al proprietario. Creati due endpoint backend prima inesistenti (GET /user/users/:id/travels e /experiences) perché quelli esistenti leggevano sempre solo l'utente autenticato. Pagina Follower/Seguiti ristilizzata in coerenza.
Tutti i widget di campo (testo, dropdown, selezione multipla, interruttore, selettore data) passati da Colors.grey[...]/Colors.black87 grezzi a DhonkoColors, campi ora riempiti (filled) invece che solo bordati. Contenuto raggruppato in card (EditProfileCard, stesso stile bianco/bordato/ombra soft del dettaglio viaggi) al posto del vecchio layout a colonna piatta con solo titoli di sezione: Informazioni base, Biografia, Preferenze personali, Preferenze di viaggio (solo Traveler), Profilo Local (solo Local). Nessuna modifica di logica o di salvataggio, solo styling.
Il gestore dell'evento di salvataggio (auth_bloc.dart) impostava isLoading: true prima della chiamata, ma dopo l'attesa emetteva solo status: authenticated senza mai riportare isLoading a false — e il metodo copyWith dello stato mantiene il valore precedente quando non viene passato esplicitamente. La pagina restava quindi bloccata per sempre nel ramo "sto caricando" (vuoto), niente pop né messaggio di successo, e nessun try/catch — un errore di rete avrebbe prodotto lo stesso blocco. Corretto aggiungendo isLoading: false su entrambi i rami (successo/errore) più un vero try/catch con messaggio d'errore leggibile. Stesso identico bug corretto anche nel gestore di caricamento foto profilo, che aveva lo stesso pattern.
Bug reale trovato: aprire una chat con un utente per la prima volta poteva restare bloccato su uno spinner per sempre. Causa: la pagina creava la chat sparando un evento sul bloc e poi ascoltava lo stream sperando in un futuro stato che la contenesse — se la creazione andava a buon fine mentre il bloc non era ancora nello stato giusto (es. subito dopo l'avvio app), nessuno stato veniva più emesso e lo spinner restava acceso all'infinito. Risolto chiamando l'use-case di creazione chat direttamente, in modo deterministico. La riga della lista chat (prima una card fluttuante con gradiente/ombra/animazioni) è stata riscritta in righe piatte edge-to-edge stile WhatsApp: avatar 56px, nome+orario in alto, anteprima messaggio+puntino verde non-letto in basso, divisore sottile rientrato dopo l'avatar. Tab dei filtri passate da pillole colorate a una sottolineatura stile segmented-tab. "Nuova chat" — prima un placeholder "funzionalità in arrivo" — apre ora un bottom sheet che carica le persone seguite e, alla selezione, apre/crea la chat con quell'utente; aggiunto anche un FAB verde sempre visibile (prima l'unico ingresso era la sola schermata vuota).
L'header usava il logo del brand come titolo con una AppBar alta 80px, e la TabBar di Material di default sotto — una combinazione pensata per la home, fuori posto su una sotto-pagina. Sostituito con lo stesso pattern back-button + titolo testuale usato ovunque nelle altre sotto-pagine di questa sessione, e la TabBar spostata in una barra più sottile (44px) con un bordo superiore leggero e un indicatore a misura di etichetta invece di quello a tutta larghezza.
Il pulsante "+" per creare un viaggio era un FloatingActionButton agganciato al centro (centerDocked) ma senza una vera tacca (CircularNotchedRectangle) ritagliata nella barra sottostante — restava semplicemente appoggiato sopra, staccato dal resto. Rimosso il FAB e ricostruita la barra con 6 posizioni equidistanti: viaggi, biglietti, "+" (ora una posizione normale della barra, un cerchio verde con ombra propria, non più flottante), gate, messaggi, ricerca.
La bolla di scorciatoia rapida alla chat compariva anche nel layout mobile, dove la barra di navigazione ha già una voce "Messaggi" a portata di pollice — un elemento in più senza motivo, oltre a sovrapporsi visivamente con altri controlli. Rimossa dal ramo mobile; resta visibile solo nei layout tablet/desktop, dove effettivamente non esiste un'alternativa già a portata di clic.
Access token portato da 15 minuti a 60 minuti, refresh token da 7 a 30 giorni. Le due durate erano sparse e duplicate a mano in 4 punti diversi (jwt.service.ts, refresh-session.service.ts, refresh-token.use-case.ts, login.use-case.ts) col rischio concreto di andare fuori sincrono — login.use-case.ts infatti dichiarava già una scadenza (expires_at) di 24 ore, completamente sbagliata rispetto ai 15 minuti reali del token. Ora c'è un'unica fonte di verità (token-ttl.constants.ts), sovrascrivibile via env per ambiente. Controllato anche il client Flutter: il flusso di refresh automatico era codice morto, commentato per intero da mesi (l'handler del 401 non puliva nemmeno i token salvati prima di rimandare al login, e l'handler del 403 — che comunque non è mai il segnale giusto per un token scaduto, quello arriva sempre come 401 — non chiamava né resolve né next, quindi la richiesta restava sospesa per sempre). Riscritto dio_client.dart: sul 401 il client ora chiama davvero POST /auth/refresh, con un solo refresh in volo anche se più richieste scadono nello stesso istante (le altre attendono lo stesso risultato), riprova la richiesta originale col nuovo access token, e solo se il refresh token stesso non è più valido pulisce tutto e manda l'utente al login.
GET /travel/:travelId e GET /event/:eventId (risoluzione della chat di gruppo) non avevano nessun controllo di autorizzazione: qualunque utente autenticato, anche mai stato nel viaggio/evento, poteva farsi restituire l'id della chat — e se non esisteva ancora, la creava lui stesso con tutti i partecipanti come membri. Stesso buco, indipendente, in chat-get-my-chats.use-case.ts (syncMissingTravelChats/syncMissingEventChats): la chat veniva creata automaticamente al primo utente qualsiasi che apriva la sua lista chat. Ora la creazione è riservata al coordinatore del viaggio/evento (chi lo ha creato, o un partecipante confermato con coordinator: true) in entrambi i punti; se la chat esiste già, richiederne l'id richiede di essere (stato) un membro — altrimenti 403. Corretto anche un buco lato socket: al reconnect il gateway univa l'utente a tutte le room delle chat di cui è mai stato membro, incluse quelle abbandonate — un utente uscito o rimosso da un viaggio continuava a ricevere in tempo reale i nuovi messaggi del gruppo. Ora si uniscono solo le chat di cui si è membro attivo (leftAt IS NULL); chi ha lasciato/è stato rimosso resta comunque in grado di aprire la conversazione e leggere la cronologia fino alla propria leftAt (già filtrata correttamente lato REST), ma non riceve più nulla dopo quella data — né via socket né via API.
Diagnosticata la causa reale: la quasi totalità dei local seedati aveva coordX/coordY scambiati rispetto alla convenzione del codice (coordX=longitudine, coordY=latitudine) — verificato concretamente: un local chiamato letteralmente "Roma" risultava a 203km dalla vera Roma, ben oltre il raggio di ricerca di default (50km). Corretti gli script sorgente del bug (seed-locals-mock.ts, enrich-travels-full.ts) e i 213 eventi già presenti nel database di sviluppo (50 righe con lo scambio esatto invertite, 7 righe "Tokyo" corrotte diversamente riportate alle coordinate reali). Aggiunto anche fix-local-coords-geocode.ts, un nuovo script permanente che geocodifica per davvero ogni destinazione (stesso GeocodingService già usato in creazione/modifica esperienza, Google Maps con fallback OpenStreetMap) — utile dopo un futuro reseed. Verificato dopo il fix: una ricerca dalla vera posizione di Roma trova ora eventi entro 50km, prima ne trovava zero. Approfittandone: GET /user/local/events/discover ora accetta lat/lng opzionali (nessun filtro di distanza se assenti) e nuovo endpoint GET /user/local/events/search-place per cercare un luogo per nome — la schermata di scoperta eventi lato Flutter è stata ristilizzata da zero (era in tema scuro/gradiente con testi hardcoded in italiano, stile estraneo al resto dell'app) con due modalità: "Vicino a te" (GPS o luogo scelto tramite ricerca) e "Tutti" (nessun filtro di posizione, eventi raggruppati per destinazione — un vero drill-down nazione/regione/provincia non è possibile con i dati attuali, solo testo libero, nessuna struttura geografica nello schema).
loadWithGps() non aveva alcun timeout esplicito attorno alle chiamate di Geolocator — sul web, se il prompt del browser per il permesso di localizzazione non riceve mai una risposta definitiva, la Future resta sospesa a tempo indefinito (il timeLimit di LocationSettings non è garantito su tutte le piattaforme). Aggiunto un .timeout(...) esplicito a ogni chiamata (stato del servizio, permesso, posizione), con fallback su uno stato d'errore invece dello spinner perenne. Backend: discover_local_events ora filtra davvero "solo disponibili" — esclude le occorrenze già al completo (partecipanti confermati ≥ posti massimi) e quelle su cui il chiamante ha già una richiesta/prenotazione attiva; il campo isAlreadyProposed del DTO era dichiarato ma hardcoded a false e mai calcolato — ora la logica esiste per davvero, applicata come filtro. Corretto anche un bug di navigazione: il tap su una card portava alla pagina principale del Local, da cui bisognava ripescare a mano la stessa data nel calendario — ora va dritto al dettaglio della singola occorrenza (LocalCalendarDetailPage, già esistente altrove ma mai collegata qui), con evento e data già fissati.
Controllando l'infrastruttura email per convertirla a React Email (richiesto esplicitamente, per template più professionali), trovati due bug reali indipendenti dalla richiesta iniziale. Il primo, grave: il template otp-email-verification.hbs non conteneva da nessuna parte il codice OTP (verificato: zero occorrenze di "otp"/"code" in tutto il file) — chi riceveva quell'email non vedeva il codice. Altri template come welcome.hbs ignoravano completamente il nome utente, e welcome_back.hbs usava {{username}} minuscolo mentre il codice passa userName — Handlebars è case-sensitive, quindi si renderizzava vuoto. Il secondo bug, strutturale: la configurazione di layoutsDir/defaultLayout/partialsDir in app.module.ts (un layout condiviso mai completato, letteralmente "HELLO WORLD" come contenuto) non ha mai avuto effetto — verificato leggendo il sorgente di HandlebarsAdapter.compile(): compila il file .hbs direttamente con la libreria handlebars grezza, ignorando del tutto quella configurazione. Ogni email è quindi sempre stata, di fatto, un file autonomo — da cui il motivo per cui tutti e 27 i template erano ciascuno una copia-incolla completa da Gmail invece di un contenuto agganciato a un layout condiviso. Rimossa la configurazione morta e i file collegati (layout stub, partial mai referenziati, 3 template orfani senza alcun metodo che li richiami).
Installato react-email/@react-email/components come devDependency, in una cartella emails/ separata da src/ (esclusa esplicitamente dalla build NestJS — zero rischio sul bundle di produzione). I template si scrivono in JSX con componenti condivisi (EmailLayout con il vero logo incorporato come data URI invece dell'URL rotta via proxy Gmail, colori brand, footer con i link legali reali), poi npm run build:emails li compila in .hbs statici con i segnaposto {{mustache}} intatti — la pipeline di invio esistente (BullMQ, override di test, rotazione oggetto) resta identica, senza alcun runtime React nell'app in produzione. Convertiti i primi 4 template come campione (benvenuto, bentornato, verifica OTP, richiesta di viaggio accettata) end-to-end verificato contro il vero HandlebarsAdapter; gli altri 23 template restano da convertire. Corretto anche l'oggetto duplicato di sendWelcome/sendWelcomeBack (stesso identico testo per registrazione e login).
Convertiti gli ultimi 24 template rimasti (i primi 4 erano già stati fatti come campione ed approvati). Estratti due componenti condivisi in più per tenere lo stile coerente senza ricostruirlo ogni volta: InfoCard (il box con titolo+dettagli di viaggio/local/evento, usato in metà dei template) e ReasonBox (box ambra per le motivazioni di rifiuto — verifica, esperienza Local). Copertura completa: autenticazione (OTP email/telefono/reset password, verifica rifiutata), viaggi (creazione/modifica/completamento/cancellazione, richieste e inviti in entrata/uscita, "parte domani"/"parte oggi" degli scheduler), roadmap (elemento aggiunto/aggiornato), Local/esperienze (creata/approvata/rifiutata, prenotazione confermata, rimosso dall'evento, nuova recensione), più l'email interna di test SMTP. Verificati 4 template extra a campione (oltre ai 4 già verificati in precedenza) end-to-end contro il vero HandlebarsAdapter: nessun segnaposto rimasto non sostituito, tutti i valori dinamici presenti. Rimossa anche public/email/templates/html/, una seconda cartella di bozze morte da 18 file .html (non .hbs, mai referenziata da nessun codice) trovata ripulendo la prima.
Analizzata la gestione del ban lato backend e Flutter per portarla sul client web Next.js. Il meccanismo di base era corretto: AuthGuard/AdminGuard rileggono il ban a ogni richiesta e rispondono HTTP 450 con i dettagli, e l'interceptor Dio porta a /ban. Trovati però cinque difetti reali. (1) users-get-by-id.use-case.ts cercava il ban con expiresAt > now, un confronto che esclude le righe con expiresAt null — cioè proprio i ban permanenti: il profilo di chi era bannato per sempre appariva normale. Estratta una regola unica (isBanActive/activeBanWhere in user-banned.exception.ts) e corretto il DTO (expiresAt: string | null); lato Flutter UserBanDto.expiresAt è diventato nullable, altrimenti il primo ban permanente avrebbe fatto fallire il parsing del profilo. (2) La pagina ban Flutter leggeva motivo e date dalla radice del body 450 invece che dal campo ban: mostrava sempre "Non specificato" e date vuote. (3) Il gateway /chat non controllava il ban: un utente bannato con il socket aperto poteva continuare a scrivere. Ora viene rifiutato alla connessione. (4) Ban e revoca non avevano effetto finché il client non faceva un'altra chiamata HTTP. (5) Trovato provando il ban dal vivo, ed era la causa più a monte: il filtro globale AllExceptionsFilter ricostruiva ogni risposta d'errore con soli messaggio e codice, scartando il campo ban — nessun client aveva mai ricevuto i dettagli del ban, e il client web non poteva riconoscere il 450. Ora il filtro li inoltra (senza l'email dell'admin, che non va esposta) e registra il 450 come warning su una riga invece che come errore con stack trace a ogni chiamata.
Per il punto 4, SessionGateway emette user_banned (stesso payload della risposta 450) e user_unbanned; al ban le connessioni chat dell'utente vengono chiuse solo sul namespace /chat e non sull'intera connessione, che il client condivide con /session, da cui deve poi arrivare la revoca. Sul client web: il middleware di api-client intercetta ogni 450, il ban vive nello store di sessione (persistito, sopravvive al ricaricamento), un unico BanGuard porta a /ban da qualunque pagina, e la sessione resta aperta — il login non controlla il ban, quindi un logout forzato non proteggerebbe nulla — con la pagina che si chiude da sola quando il ban viene revocato o scade. Lì "Contatta supporto" apre il Centro assistenza; su Flutter resta un segnaposto.
Blocco. Si poteva continuare a seguire un utente bloccato. Il trigger che avrebbe dovuto evitarlo (prisma/trigger_blocker.sql) esisteva, ma nessuno lo applicava al database. Il nuovo public/sql/trigger_blocker.sql, rieseguibile senza effetti collaterali, fa tre cose: al blocco cancella i follow in entrambe le direzioni e le notifiche di follow tra i due; impedisce un nuovo follow finché il blocco esiste (così non nasce nemmeno la chat privata creata dal trigger del follow); ripulisce i follow già rimasti tra utenti bloccati. Va eseguito a mano, perché Prisma non gestisce i trigger.
Segnalazioni. Le segnalazioni utente finivano già in UserWarn, con lo stato gestito dagli admin, ma chi segnalava non poteva vederle. Nuovo GET /user/warn: restituisce le proprie segnalazioni con stato e date, senza l'admin che le ha gestite. Sul client web, Impostazioni → "Le mie segnalazioni" mostra l'elenco e il dettaglio al clic. Bug trovato di passaggio: POST /user/warn/:userId, l'endpoint che usa Flutter, leggeva title/reason mentre Flutter manda reason/description, quindi l'inserimento falliva. In più aveva invertiti chi segnala e chi è segnalato: una volta corretti i campi, il segnalato avrebbe visto nella sua lista il nome di chi l'aveva segnalato. Corretti entrambi.
Verifica account e diventa local. Sul client web mancavano entrambe le voci del menu. La verifica d'identità non esisteva proprio: /verify è la verifica OTP dell'onboarding. Nuova pagina /profile/verify con stato, motivo del rifiuto, storico e i 5 passi di Flutter (tipo documento, fronte, retro, selfie, riepilogo). Formato e peso delle foto sono controllati nel client, perché il backend non li filtra. Una foto mancante restituiva 200 con un messaggio e sembrava un invio riuscito: ora il client la tratta come errore. "Diventa local" apre ora la candidatura anche al TRAVELER: non chiede di nuovo i dati personali e avvisa prima dell'invio che il ruolo diventa PENDING_LOCAL, con l'app da traveler bloccata fino all'approvazione.
Le card delle esperienze nel Gate e sul profilo portano a /locals/:id, ma quella rotta mostrava solo "in arrivo". Ora è la scheda vera, portata da LocalDetailsPage e LocalCalendarPage di Flutter. Contiene intestazione (targa DHONKO o SPECIAL, destinazione, durata, livelli, dimensione del gruppo), racconto, cosa è incluso, tipo di esperienza e lingue, prezzi della programmazione con sconti di gruppo, requisiti, termine di prenotazione e politica di cancellazione, e l'organizzatore. In Flutter il calendario è una seconda pagina; qui sta nella scheda: griglia del mese con i giorni che hanno date, e ogni orario apre il dettaglio della singola data (/gate/local/events/:id, già portato). Nessun endpoint nuovo: usa GET /user/local/:localId e /calendar. Un'esperienza non approvata (403) o inesistente (404) mostra un messaggio dedicato. Restano da portare le due azioni dell'organizzatore che in Flutter partono da qui: modifica dell'esperienza e annullamento di più date insieme.
Nuovo script npm run seed:simulation (src/scripts/seed-simulation/) che popola il database locale con dati coerenti su cui simulare l'uso reale dell'app. Crea 100 utenti con tutti i ruoli (5 admin, 2 Dhonko, 25 local, 5 local in attesa, registrati, non verificati e 58 traveler). Sono registrati anche su Firebase Auth, perché il login del backend chiede a Firebase un custom token e senza quello il client web blocca l'accesso. Hanno tutti la stessa password e un'email su un dominio riservato (@dhonko-seed.example.com), così le email automatiche del login non arrivano a caselle reali.
Intorno agli utenti: follow e amicizie, blocchi, recensioni, segnalazioni, un ban per utente (16 attivi, permanenti o temporanei, gli altri scaduti), cronologia ricerche, impostazioni, verifiche e candidature local. Poi 100 esperienze local, il 40% intorno a Roma così "Vicino a te" trova eventi, con 400 date, partecipanti e pagamenti; 100 viaggi in tutti gli stati con partecipanti, programma votato, eventi proposti e Book fotografico; chat di viaggio, di evento e dirette con messaggi, risposte, reazioni e segnalazioni; notifiche di ogni tipo, log e broadcast admin. Una parte dei dati coinvolge anche gli utenti reali già presenti (follower, chat, notifiche).
Il seed tocca solo i propri dati e si può rilanciare: prima cancella il seed precedente, riconoscendolo dagli utenti di quel dominio e da un marcatore nei campi JSON. Con --purge cancella soltanto, account Firebase compresi; con --no-firebase non tocca Firebase. Si rifiuta di girare se DATABASE_URL non punta a un database locale. Limiti noti: i pagamenti hanno PaymentIntent Stripe finti, quindi un rimborso reale su Stripe fallisce; i token FCM sono finti e il processor li elimina al primo invio.
Gestione del gruppo. Sul client web sono arrivate le pagine partecipanti (ricerca, filtri per ruolo, rimozione, "Chiudi stato viaggio"), richieste di partecipazione, inviti con selezione multipla, Travel Match con le carte da scorrere, impostazioni del match e modifica del viaggio. Nel backend: la modifica del viaggio ricostruiva le foto solo da quelle caricate nella richiesta, quindi ogni modifica senza nuove foto le cancellava tutte; rispondeva sempre { success: true } anche quando falliva; e non controllava chi la chiedeva — chiunque poteva riscrivere qualsiasi viaggio. Stessa assenza di controllo su PUT /user/match/:id. Ora solo creatore o coordinatore, gli errori arrivano al client e le foto esistenti restano (con keepPhotos per scegliere quali). Eliminare un viaggio rispondeva sempre 500, anche da Flutter: le relazioni verso Travel non sono in cascata e c'è sempre almeno il creatore tra i partecipanti; ora programma, voti, partecipanti, salvataggi e revisioni si eliminano nella stessa transazione, e la chat di gruppo resta ma scollegata. Su Flutter restano tre difetti: le Impostazioni Match mostrano "aggiornato" senza chiamare nessuna API, la modifica manda tipi di viaggio e compagni con nomi che il backend non legge, e i posti cercati ripartono da quelli liberi.
Programma del viaggio. Portati dettaglio, voto, elenco dei votanti, creazione e modifica di spostamenti, pernottamenti e attività, e la scelta delle esperienze locali da proporre. Bug del backend corretti: modificare uno spostamento falliva sempre (gli orari "HH:MM" diventavano una data non valida); gli orari si salvavano nel fuso del server e tornavano spostati; una proposta senza data (facoltativa nel modulo, obbligatoria nel database) e un'attività senza orari fallivano con 500; il link di uno spostamento veniva ignorato in creazione; il dettaglio di un'attività non restituiva gli orari. Nessun permesso su voti, modifiche e dettagli — bastava conoscere un id — e le eliminazioni cancellavano i voti prima ancora di controllare chi chiedeva. Ora le regole sono in un punto solo (schedule/schedule-access.ts): leggere, proporre e votare solo creatore e partecipanti confermati, modificare ed eliminare chi ha proposto o un coordinatore. Su Flutter la modifica di pernottamenti e attività chiama rotte che non esistono, e le traduzioni dei tipi di attività usano ancora i valori di un enum precedente. Sul web, quando il programma è vuoto le sezioni restano visibili con "Proponi": in Flutter la prima proposta si poteva fare solo scegliendo prima un filtro di sezione.
Portate sotto /admin le aree del pannello di Flutter, ciascuna con la sua pagina di dettaglio: utenti, verifiche identità, local in attesa, viaggi, esperienze, segnalazioni, pagamenti, recensioni, moderazione chat, log attività, notifiche broadcast, configurazione e contenuti Gate. Nel backend, a supporto: il rimborso admin passa davvero da Stripe; l'eliminazione di un viaggio moderato elimina in una transazione anche le righe collegate, TravelReview compresa; nuova POST /admin/chat/reported/:reportId/resolve per chiudere una segnalazione di chat; il dettaglio utente porta lo storico delle verifiche identità e quello del viaggio le decisioni di revisione.
Home dell'host. Un local, entrando, vedeva il feed dei viaggi del traveler (anche su Flutter), pur non potendo partecipare a nessun viaggio. Ora la sua navigazione è Dashboard, Calendario, Viaggi, Gate, Chat. La dashboard mette insieme incassi, cose da gestire, prossime date ed esperienze (GET /user/local/my/dashboard); il calendario mostra tutte le date di tutte le esperienze (GET /user/local/my/events) e ne annulla più insieme; "Viaggi" elenca quelli aperti che passano vicino alle date dell'host (GET /user/local/my/nearby-travels). Le regole "entro 50 km e nei giorni del viaggio" stanno ora in un punto solo (common/utils/distance.ts), usato sia dalla lista delle esperienze proponibili sia dal controllo della proposta.
Proposte. L'host può proporre una sua data al programma di un viaggio senza farne parte (prima potevano solo partecipanti e creatore), o a una persona in chat con POST /user/local/event/:eventId/propose-to/:userId: il messaggio resta testo leggibile da qualunque client, con il percorso dell'evento, e il web lo mostra come card. I local non possono più essere invitati, chiedere di partecipare, confermare un invito o essere accettati: travel-invite.use-case.ts accettava qualunque utente e la ricerca "Invita compagni" mostrava anche i local.
Sicurezza della chat. Tre buchi trovati progettando le proposte, perché un host può scrivere a chiunque. (1) Il blocco non fermava la chat: un utente bloccato poteva ancora aprire una chat diretta e scrivere. Ora apertura e invio (REST e socket) rispondono 403 USER_BLOCKED, e l'errore del socket porta il codice. (2) Nessuna rotta lato utente creava un ChatReport, quindi la coda admin di moderazione chat restava vuota: nuova POST /user/chat/:chatId/report, con "Segnala messaggio" e "Segnala conversazione" sul web. (3) Non c'era modo di scegliere da chi ricevere messaggi: nuovo modello UserPrivacySettings (unica modifica allo schema, migrazione 20260915090000_add_user_privacy_settings) con "Chi può scrivermi" e "Proposte dai local", in Impostazioni. Nel dettaglio viaggio, su web e Flutter, l'ingresso alla chat di gruppo (card e icona) si vede solo a chi partecipa o coordina, e su Flutter il local non vede più "Chiedi di partecipare". Visto ma non corretto: la richiesta diretta di partecipazione a una data conferma senza pagamento e ha il controllo dei posti commentato.
Nuovo npm run seed:local-demo (src/scripts/seed-simulation/local-demo.ts): crea sul database locale un local con cui fare login (demo.local@dhonko-seed.example.com) e un traveler (demo.traveler@…), stessa password del seed di simulazione, più 24 ospiti. Il local ha 13 esperienze: approvate tra Roma, Castelli Romani, Tivoli, Ostia, Firenze e Napoli, una in revisione, una rifiutata con il motivo nel registro admin, una approvata senza date e una rimossa. Le date coprono gli ultimi 75 giorni e i prossimi 150 (circa 950), con iscritti, pagamenti anche rimborsati, alcune date imminenti sotto il minimo, una in corso e 14 recensioni. Ci sono 11 viaggi: 7 passano vicino alle sue date (su 3 c'è già una sua proposta in attesa, accettata o rifiutata), 4 servono a verificare le esclusioni (fuori raggio, in revisione, oltre le date, concluso).
Tocca solo gli utenti demo.*: purgeSeedData accetta ora un prefisso email, e la pulizia elimina anche le voci del registro admin legate a esperienze e viaggi del seed. Con --purge cancella soltanto; il --purge del seed di simulazione, che usa lo stesso dominio, cancella anche questi account.
Il problema. Esisteva un solo modo di programmare: una settimana tipo, con un unico orario di riferimento per regola. "Evento speciale" era solo un'etichetta, e il concerto di una sera veniva registrato come una ricorrenza. Le date nascevano per 90 giorni e poi nessuno ne creava altre: un lido "tutta la stagione" si fermava lì. Alla creazione nasceva anche una chat di gruppo per ogni data, centinaia in una volta. Gli orari erano calcolati nel fuso del server, e lo stato delle date ("in corso", "conclusa") non lo aggiornava nessun job.
Adesso. LocalSchedule ha un tipo (LocalScheduleKind: SINGLE, DATES, WEEKLY), l'orario di fine, i giorni esclusi e generatedUntil. Ogni regola è una fascia oraria. La logica sta in un punto solo, puro e senza database (local/schedule/schedule-plan.ts): la usano la creazione, le nuove rotte /user/local/:localId/schedules e /user/local/schedules/:scheduleId e il job LocalEventsScheduler. Il job ogni 10 minuti aggiorna gli stati e tiene pronte le date dei prossimi 120 giorni, così una ricorrenza può non avere fine. Giorni e orari sono sempre nel fuso di Roma (common/utils/zoned-time.ts). La logica è stata provata sui casi reali, cambio dell'ora legale compreso: concerto (1 data), partite in casa (3 date), bar ogni venerdì di ottobre dalle 21 alle 2 (5 date che finiscono il giorno dopo), lido lun–ven senza fine con un festivo escluso (85 date), scuola il lunedì in quattro fasce (4 regole da 14 date). Modificare o fermare una programmazione tocca solo le date senza iscritti, pagamenti, proposte o chat. La chat di una data nasce quando serve. Il formato di Flutter resta accettato com'era.
Client web. Nuova pagina /locals/create, da "Crea esperienza" nella dashboard: le sezioni della procedura Flutter su una pagina, con l'editor delle tre modalità e l'anteprima delle date. Nella scheda dell'esperienza il creatore gestisce la programmazione: aggiunge date, cambia una ricorrenza, ferma, cambia posti e prezzi. Da applicare: npx prisma migrate deploy e npx prisma generate a server fermo.