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:
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user