Initial commit: TravelHub multi-tenant flight booking platform

Angular + Go + PostgreSQL sample with strict per-tenant isolation.

- Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool)
- Frontend: Angular 22 SPA (search, checkout, bookings, dashboard)
- Database: schema, seed data and demo bookings migrations
- Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e),
  plus a generated evidence report with PDF export
- Security: whitebox VAPT report under security/

Secrets (.env, tokens, DB credentials) are git-ignored.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+162
View File
@@ -0,0 +1,162 @@
package handlers
import (
"encoding/json"
"net/http"
"net/mail"
"strings"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListBookings handles GET /api/bookings, optionally filtered by status.
func (h *Handler) ListBookings(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
status := strings.TrimSpace(r.URL.Query().Get("status"))
if status != "" && status != "confirmed" && status != "cancelled" {
writeError(w, http.StatusBadRequest, "status must be confirmed or cancelled")
return
}
limit := intParam(r, "limit", 25, 1, 100)
offset := intParam(r, "offset", 0, 0, 100000)
bookings, total, err := h.Repo.ListBookings(r.Context(), id.Tenant.ID, status, limit, offset)
if err != nil {
h.fail(w, err, "list bookings")
return
}
writeJSON(w, http.StatusOK, page[models.Booking]{
Items: bookings, Total: total, Limit: limit, Offset: offset,
})
}
// GetBooking handles GET /api/bookings/{id}.
func (h *Handler) GetBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.BookingByID(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "get booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// CreateBooking handles POST /api/bookings.
func (h *Handler) CreateBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
var req models.CreateBookingRequest
dec := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
dec.DisallowUnknownFields()
if err := dec.Decode(&req); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body: "+err.Error())
return
}
if msg := validateBooking(&req); msg != "" {
writeError(w, http.StatusBadRequest, msg)
return
}
booking, err := h.Repo.CreateBooking(r.Context(), id.Tenant.ID, id.User.ID, req)
if err != nil {
h.fail(w, err, "create booking")
return
}
writeJSON(w, http.StatusCreated, booking)
}
// CancelBooking handles POST /api/bookings/{id}/cancel and returns the seats
// to inventory.
func (h *Handler) CancelBooking(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
bookingID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid booking id")
return
}
booking, err := h.Repo.CancelBooking(r.Context(), id.Tenant.ID, bookingID)
if err != nil {
h.fail(w, err, "cancel booking")
return
}
writeJSON(w, http.StatusOK, booking)
}
// validateBooking returns an empty string when the payload is usable, or a
// message naming the first problem. Normalisation happens here too, so the
// repository only ever sees trimmed values.
func validateBooking(req *models.CreateBookingRequest) string {
if req.FlightID == uuid.Nil {
return "flightId is required"
}
req.ContactEmail = strings.TrimSpace(req.ContactEmail)
if req.ContactEmail == "" {
return "contactEmail is required"
}
if _, err := mail.ParseAddress(req.ContactEmail); err != nil {
return "contactEmail is not a valid email address"
}
req.ContactPhone = strings.TrimSpace(req.ContactPhone)
if len(req.Passengers) == 0 {
return "at least one passenger is required"
}
if len(req.Passengers) > 9 {
return "a single booking may carry at most 9 passengers"
}
for i := range req.Passengers {
p := &req.Passengers[i]
p.FirstName = strings.TrimSpace(p.FirstName)
p.LastName = strings.TrimSpace(p.LastName)
p.PassportNumber = strings.TrimSpace(p.PassportNumber)
p.DateOfBirth = strings.TrimSpace(p.DateOfBirth)
if p.FirstName == "" || p.LastName == "" {
return "every passenger needs a first and last name"
}
}
return ""
}
// Dashboard handles GET /api/dashboard.
func (h *Handler) Dashboard(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
stats, err := h.Repo.DashboardStats(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "dashboard stats")
return
}
writeJSON(w, http.StatusOK, stats)
}