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>
163 lines
4.1 KiB
Go
163 lines
4.1 KiB
Go
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)
|
|
}
|