{
  "slug": "travel-agency",
  "title": "Gestionale per agenzia viaggi",
  "label": "agenzia viaggi",
  "kind": "personal",
  "tagline": "Un’applicazione Spring Boot su MariaDB, nata come console e poi web.",
  "summary": "Un’applicazione Spring Boot su MariaDB, nata come console e poi web, con login a sessione e BCrypt, pagamenti PayPal e biglietti PDF con QR code.",
  "stack": [
    "Java 25",
    "Spring Boot 3.5",
    "Spring Security",
    "Spring Data JPA",
    "MariaDB"
  ],
  "status": "completed",
  "lead": "Cercare un viaggio, prenotarlo per più persone, pagarlo con PayPal e scaricare il biglietto: il gestionale di una piccola agenzia, in Spring Boot.",
  "facts": [
    {
      "name": "pagamenti",
      "value": "PayPal, provato fino in fondo nella sandbox",
      "href": null
    },
    {
      "name": "codice",
      "value": "repository privato",
      "href": null
    }
  ],
  "caseStudy": {
    "sections": [
      {
        "id": "starting-point",
        "title": "Il punto di partenza",
        "paragraphs": [
          "Il mio primo progetto è stato una biblioteca in Java puro, con i libri in un file di testo. L’agenzia riprende la stessa divisione in entità e service, in un altro dominio.",
          "L’obiettivo era fare tutto il percorso di un’applicazione Spring: dai dati in memoria a un database relazionale, dalla console al web, fino al pagamento e al biglietto. Le scelte e il backend sono miei; le pagine HTML le ha generate l’AI."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "persistence",
        "title": "La persistenza, un passo alla volta",
        "paragraphs": [
          "Dopo la biblioteca sono arrivato a JPA un passo alla volta, e ogni passo risolve un problema di quello prima."
        ],
        "body": {
          "kind": "steps",
          "flow": {
            "label": null,
            "steps": [
              {
                "title": "La biblioteca",
                "code": null,
                "text": "Java puro, i libri in un file di testo",
                "date": "2025-05",
                "outcome": "il punto di partenza",
                "outside": "un altro progetto"
              },
              {
                "title": "Liste in memoria",
                "code": null,
                "text": "viaggi e prenotazioni in liste in memoria",
                "date": "2025-10-16",
                "outcome": "al riavvio si perdono",
                "outside": null
              },
              {
                "title": "JDBC a mano",
                "code": null,
                "text": "SQL nel codice, ogni service apre la sua connessione",
                "date": "2025-10-24",
                "outcome": "i dati sopravvivono al riavvio",
                "outside": null
              },
              {
                "title": "DataSource e HikariCP",
                "code": null,
                "text": "Spring inietta un DataSource con il pool",
                "date": "2025-10-29",
                "outcome": "connessioni restituite al pool e riusate",
                "outside": null
              },
              {
                "title": "JPA e Spring Data",
                "code": null,
                "text": "entità e repository",
                "date": "2025-12-01",
                "outcome": "il codice JDBC ripetuto sparisce",
                "outside": null
              }
            ],
            "caption": null,
            "base": {
              "name": "MariaDB",
              "text": "lo stesso database, cambia come ci si arriva",
              "from": 3
            }
          },
          "quiet": null
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "architecture",
        "title": "Come è fatto",
        "paragraphs": [
          "La console è venuta prima, con le regole già nei service. Il web si è aggiunto sopra gli stessi service, nello stesso processo, senza toccare la logica delle prenotazioni."
        ],
        "body": {
          "kind": "layerStack",
          "columns": [
            [
              {
                "name": "Console",
                "text": "l’interfaccia di partenza",
                "short": null
              },
              {
                "name": "Menu",
                "text": "solo presentazione, nessuna regola",
                "short": "nessuna regola"
              }
            ],
            [
              {
                "name": "Browser",
                "text": "pagine Thymeleaf e JavaScript",
                "short": "Thymeleaf e JavaScript"
              },
              {
                "name": "Spring Security",
                "text": "form login e sessione",
                "short": "form login, sessione"
              },
              {
                "name": "Controller",
                "text": "sottili: pagine e JSON",
                "short": "sottili"
              }
            ]
          ],
          "core": {
            "name": "service",
            "text": "le regole e le transazioni; verso l’alto escono solo DTO",
            "short": null,
            "parts": [
              {
                "name": "utenti",
                "focus": false
              },
              {
                "name": "viaggi",
                "focus": false
              },
              {
                "name": "prenotazioni",
                "focus": true
              },
              {
                "name": "biglietti",
                "focus": false
              },
              {
                "name": "pagamenti",
                "focus": false
              },
              {
                "name": "email",
                "focus": false
              },
              {
                "name": "token",
                "focus": false
              }
            ]
          },
          "below": [
            {
              "name": "Repository",
              "text": "Spring Data JPA",
              "short": null
            },
            {
              "name": "HikariCP → MariaDB",
              "text": "lo stesso database per la console e per il web",
              "short": null
            }
          ],
          "aside": {
            "caption": "i service parlano anche con",
            "services": [
              {
                "name": "PayPal",
                "text": "API REST, sandbox",
                "short": "API REST"
              },
              {
                "name": "Gmail",
                "text": "email di conferma e di reset",
                "short": "email"
              }
            ]
          }
        },
        "note": "Il menu della console e la cattura di PayPal creano la prenotazione con lo stesso metodo del service.",
        "subsection": null
      },
      {
        "id": "purchase-flow",
        "title": "Dalla ricerca al biglietto",
        "paragraphs": [
          "Ordine e cattura passano dal server, con le sue credenziali, e sul web la prenotazione nasce solo quando PayPal gli risponde che il pagamento è completato."
        ],
        "body": {
          "kind": "sequenceFlow",
          "participants": [
            {
              "name": "Browser",
              "role": "Thymeleaf e JavaScript",
              "short": "browser"
            },
            {
              "name": "Spring Boot",
              "role": "controller e service",
              "short": "server"
            },
            {
              "name": "PayPal",
              "role": "API REST, sandbox",
              "short": "PayPal"
            },
            {
              "name": "MariaDB",
              "role": "via Spring Data JPA",
              "short": "MariaDB"
            }
          ],
          "steps": [
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "la ricerca: destinazione, prezzo, posti",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                },
                {
                  "from": 1,
                  "to": 3,
                  "label": {
                    "text": "una query, con i filtri opzionali",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 1,
                "title": {
                  "text": "la ricerca",
                  "code": false
                },
                "text": "destinazione, prezzo e posti, in una query con filtri opzionali"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "apre la conferma",
                    "code": false
                  },
                  "note": "prezzo, sconto, numero di persone",
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 1,
                "title": {
                  "text": "la conferma",
                  "code": false
                },
                "text": "prezzo, sconto, numero di persone"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "chiede l’ordine",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                },
                {
                  "from": 1,
                  "to": 2,
                  "label": {
                    "text": "crea l’ordine",
                    "code": false
                  },
                  "note": "con le credenziali del server",
                  "lit": false
                }
              ],
              "summary": {
                "from": 1,
                "to": 2,
                "title": {
                  "text": "crea l’ordine",
                  "code": false
                },
                "text": "con le credenziali del server"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 2,
                  "label": {
                    "text": "il cliente approva nel popup di PayPal",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 2,
                "title": {
                  "text": "il cliente approva",
                  "code": false
                },
                "text": "nel popup di PayPal"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "chiede la cattura",
                    "code": false
                  },
                  "note": null,
                  "lit": false
                },
                {
                  "from": 1,
                  "to": 2,
                  "label": {
                    "text": "capture → COMPLETED",
                    "code": true
                  },
                  "note": null,
                  "lit": true
                }
              ],
              "summary": {
                "from": 1,
                "to": 2,
                "title": {
                  "text": "capture → COMPLETED",
                  "code": true
                },
                "text": "su richiesta del browser"
              }
            },
            {
              "messages": [
                {
                  "from": 1,
                  "to": 3,
                  "label": {
                    "text": "prenotazione salvata, posti scalati",
                    "code": false
                  },
                  "note": "una transazione sola",
                  "lit": true
                }
              ],
              "summary": {
                "from": 1,
                "to": 3,
                "title": {
                  "text": "prenotazione e posti",
                  "code": false
                },
                "text": "una transazione sola"
              }
            },
            {
              "messages": [
                {
                  "from": 0,
                  "to": 1,
                  "label": {
                    "text": "scarica il biglietto",
                    "code": false
                  },
                  "note": "PDF con QR, solo se la prenotazione è tua",
                  "lit": false
                }
              ],
              "summary": {
                "from": 0,
                "to": 1,
                "title": {
                  "text": "il biglietto",
                  "code": false
                },
                "text": "PDF con QR, solo se la prenotazione è tua"
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "data-model",
        "title": "Il modello dei dati",
        "paragraphs": [
          "Solo le colonne che raccontano una scelta: i posti come contatore sul viaggio, il prezzo congelato nella prenotazione, il token che scade e vale una volta."
        ],
        "body": {
          "kind": "dataModel",
          "tables": [
            {
              "name": "users",
              "columns": [
                {
                  "name": "email",
                  "note": null,
                  "key": false
                },
                {
                  "name": "password",
                  "note": "solo l’hash",
                  "key": true
                },
                {
                  "name": "enabled",
                  "note": "dopo la conferma dell’email",
                  "key": true
                }
              ],
              "focus": false
            },
            {
              "name": "booked_trips",
              "columns": [
                {
                  "name": "people_count",
                  "note": "persone",
                  "key": true
                },
                {
                  "name": "final_price",
                  "note": "totale pagato, congelato",
                  "key": true
                },
                {
                  "name": "applied_discount",
                  "note": "sconto di allora, congelato",
                  "key": true
                }
              ],
              "focus": true
            },
            {
              "name": "trips",
              "columns": [
                {
                  "name": "price",
                  "note": "prezzo di oggi",
                  "key": false
                },
                {
                  "name": "discount_percentage",
                  "note": "sconto di oggi",
                  "key": false
                },
                {
                  "name": "available_seats",
                  "note": "contatore dei posti",
                  "key": true
                }
              ],
              "focus": false
            },
            {
              "name": "followed_trips",
              "columns": [
                {
                  "name": "user_id",
                  "note": null,
                  "key": false
                },
                {
                  "name": "trip_id",
                  "note": null,
                  "key": false
                }
              ],
              "focus": false
            },
            {
              "name": "confirmation_token",
              "columns": [
                {
                  "name": "token",
                  "note": "casuale",
                  "key": true
                },
                {
                  "name": "expires_at",
                  "note": "scadenza breve",
                  "key": true
                },
                {
                  "name": "confirmed_at",
                  "note": "vale una volta",
                  "key": true
                },
                {
                  "name": "token_type",
                  "note": "conferma o reset",
                  "key": true
                }
              ],
              "focus": false
            }
          ],
          "relations": [
            {
              "from": "users",
              "to": "booked_trips",
              "verb": "prenota",
              "through": null
            },
            {
              "from": "trips",
              "to": "booked_trips",
              "verb": "è prenotato in",
              "through": null
            },
            {
              "from": "users",
              "to": "trips",
              "verb": "segue",
              "through": "followed_trips"
            },
            {
              "from": "users",
              "to": "confirmation_token",
              "verb": "riceve",
              "through": null
            }
          ],
          "around": "e intorno a users"
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "decisions",
        "title": "Decisioni",
        "paragraphs": [],
        "body": {
          "kind": "decisions",
          "records": [
            {
              "number": 1,
              "status": "accepted",
              "title": "La sessione, non un token",
              "context": "Le pagine HTML le genera lo stesso server, con Thymeleaf: non c’è un frontend separato.",
              "decision": "Login con il form di Spring Security e la sessione sul server, non con un JWT.",
              "consequences": "Login, logout e redirect li gestisce Spring Security senza codice in più, e il logout chiude davvero la sessione. Nel gestionale per autoscuole, con un client separato, ho usato invece un JWT."
            },
            {
              "number": 2,
              "status": "accepted",
              "title": "Il prezzo si congela nella prenotazione",
              "context": "Prezzo e sconto di un viaggio possono cambiare dopo l’acquisto. I posti non sono assegnati: conta solo quanti ne restano.",
              "decision": "La prenotazione salva il totale pagato, lo sconto applicato in quel momento e il numero di persone. Il viaggio tiene un contatore dei posti liberi.",
              "consequences": "Lo storico resta quello che il cliente ha pagato, anche se poi l’agenzia cambia lo sconto. Una riga per posto non serviva: senza posti assegnati basta un contatore."
            },
            {
              "number": 3,
              "status": "accepted",
              "title": "Un token nel database per conferma e reset",
              "context": "In due momenti serve provare che un’email è di chi la usa: alla registrazione e quando si dimentica la password.",
              "decision": "Una tabella sola: un token casuale che scade presto e vale una volta sola, con un tipo, conferma o reset. L’account resta disattivato finché l’email non è confermata.",
              "consequences": "Il token si invalida scrivendo una data, cosa che un token firmato e senza stato non permette senza una lista di revoca. Il tipo impedisce che un link di conferma cambi una password, e il reset è arrivato dopo con un tipo in più, non con un’altra tabella."
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "passwords",
        "title": "Le password",
        "paragraphs": [],
        "body": {
          "kind": "beforeAfter",
          "problems": [
            {
              "title": "Dal testo in chiaro all’hash",
              "text": "All’inizio le password erano salvate in chiaro, e il login le confrontava così com’erano. Poi ho studiato come funziona l’hashing e cos’è BCrypt, e l’ho adottato.",
              "comparison": {
                "form": "sides",
                "before": {
                  "value": "in chiaro",
                  "text": "salvata e confrontata così com’era"
                },
                "after": {
                  "value": "BCrypt",
                  "text": "nel database solo l’hash, verificato al login"
                }
              }
            }
          ]
        },
        "note": null,
        "subsection": null
      },
      {
        "id": "status",
        "title": "Lo stato",
        "paragraphs": [
          "Completo, con PayPal in sandbox. Il progetto successivo è il gestionale per autoscuole."
        ],
        "body": null,
        "note": null,
        "subsection": null
      },
      {
        "id": "lessons",
        "title": "Cosa ho imparato",
        "paragraphs": [
          "Scrivere a mano JDBC, le connessioni e il passaggio dalle righe agli oggetti, prima di usare JPA, mi ha fatto capire cosa fa un ORM al posto mio e dove guardare quando fa qualcosa che non mi aspetto. Con PayPal ho visto che un pagamento non è una chiamata sola ma un ordine che passa per più stati, e che la prenotazione deve nascere solo dalla cattura confermata.",
          "Rifacendolo, terrei i posti prima di chiedere il pagamento: oggi si scalano dopo la cattura, e se finiscono nel frattempo il cliente ha pagato per una prenotazione che non nasce."
        ],
        "body": null,
        "note": null,
        "subsection": null
      }
    ]
  },
  "_links": {
    "self": {
      "href": "/it/projects/travel-agency"
    },
    "alternate": {
      "href": "/en/projects/travel-agency",
      "hreflang": "en"
    },
    "up": {
      "href": "/it/projects"
    }
  }
}
