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
+19
View File
@@ -0,0 +1,19 @@
# Copy to .env and fill in real values. .env is git-ignored.
# --- database ---------------------------------------------------------------
# Either set DATABASE_URL directly, or set the individual DB_* parts below and
# let the app assemble the URL. DATABASE_URL wins if both are present.
# DATABASE_URL=postgres://postgres:password@localhost:5432/travelhub?sslmode=disable
DB_HOST=localhost
DB_PORT=5432
DB_USER=postgres
DB_PASSWORD=change-me
DB_NAME=travelhub
DB_SSLMODE=disable
# --- server -----------------------------------------------------------------
PORT=8080
# Comma-separated list of origins allowed to call the API from a browser.
CORS_ORIGINS=http://localhost:4200
+80
View File
@@ -0,0 +1,80 @@
// Command api serves the TravelHub multi-tenant flight booking API.
package main
import (
"context"
"errors"
"log/slog"
"net/http"
"os"
"os/signal"
"syscall"
"time"
"github.com/travelswitch/travelhub/backend/internal/config"
"github.com/travelswitch/travelhub/backend/internal/database"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
func main() {
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
}))
if err := run(logger); err != nil {
logger.Error("fatal", "error", err)
os.Exit(1)
}
}
func run(logger *slog.Logger) error {
cfg, err := config.Load()
if err != nil {
return err
}
// Interrupt cancels this context, which unwinds both the pool and the
// server below.
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
pool, err := database.Connect(ctx, cfg.DatabaseURL)
if err != nil {
return err
}
defer pool.Close()
logger.Info("connected to postgres")
repo := repository.New(pool)
h := handlers.New(repo, logger)
srv := &http.Server{
Addr: ":" + cfg.Port,
Handler: httpapi.New(h, repo, logger, cfg.CORSOrigins),
ReadHeaderTimeout: 10 * time.Second,
ReadTimeout: 30 * time.Second,
WriteTimeout: 30 * time.Second,
IdleTimeout: 60 * time.Second,
}
errCh := make(chan error, 1)
go func() {
logger.Info("listening", "addr", srv.Addr, "cors", cfg.CORSOrigins)
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
errCh <- err
}
}()
select {
case err := <-errCh:
return err
case <-ctx.Done():
logger.Info("shutting down")
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
return srv.Shutdown(shutdownCtx)
}
+512
View File
@@ -0,0 +1,512 @@
mode: set
github.com/travelswitch/travelhub/backend/internal/config/config.go:26.2,27.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:28.2,31.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:33.2,33.49 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:34.3,35.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:36.3,42.1 7 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:43.3,43.17 7 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:44.4,45.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:49.3,55.14 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:58.2,58.17 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:66.2,67.16 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:68.3,69.1 1 0
github.com/travelswitch/travelhub/backend/internal/config/config.go:71.2,71.25 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:72.3,73.25 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:74.4,75.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:76.3,77.20 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:78.4,79.1 1 0
github.com/travelswitch/travelhub/backend/internal/config/config.go:80.3,80.15 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:86.2,87.16 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:88.3,89.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:90.2,91.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:92.2,93.21 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:94.3,95.49 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:96.4,96.12 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:98.3,99.13 2 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:100.4,100.12 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:102.3,104.1 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:106.3,106.22 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:107.4,108.55 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:109.5,110.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:113.3,113.46 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:114.4,115.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:117.2,117.13 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:121.2,121.34 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:122.3,123.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:124.2,124.17 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:128.2,130.26 3 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:131.3,131.40 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:132.4,133.1 1 1
github.com/travelswitch/travelhub/backend/internal/config/config.go:135.2,135.12 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:28.2,28.46 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:29.3,29.72 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:30.4,30.17 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:31.5,31.37 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:32.6,35.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:37.4,37.24 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:49.2,51.1 2 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:55.2,55.46 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:56.3,56.72 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:57.4,67.1 4 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:73.2,74.28 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:75.3,76.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:78.2,78.46 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:79.3,79.72 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:80.4,81.40 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:82.5,88.1 7 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:89.5,90.1 7 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:92.4,92.38 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:93.5,95.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:96.4,96.24 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:108.2,108.46 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:109.3,109.72 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:110.4,111.18 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:112.5,115.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:117.4,118.11 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:119.5,121.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:123.4,125.18 3 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:126.5,126.47 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:129.6,131.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:132.5,134.11 3 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:137.4,138.18 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:139.5,139.47 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:143.6,146.1 3 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:147.5,149.11 3 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:152.4,152.89 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:153.5,155.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:160.4,160.71 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:161.5,163.1 2 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:165.4,166.41 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:173.2,173.71 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:174.3,175.34 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:176.4,178.1 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:179.3,179.23 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:189.2,189.65 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:190.3,191.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:193.2,194.50 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:195.3,196.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:197.2,198.44 2 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:199.3,200.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:201.2,201.11 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:208.2,209.1 1 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:212.2,215.1 3 1
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:219.2,219.36 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:220.3,221.1 1 0
github.com/travelswitch/travelhub/backend/internal/middleware/middleware.go:222.2,222.10 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:15.2,16.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:17.3,18.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:20.2,21.68 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:22.3,24.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:26.2,28.1 4 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:29.2,30.16 4 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:31.3,33.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:35.2,37.4 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:42.2,43.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:44.3,45.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:47.2,48.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:49.3,51.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:53.2,54.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:55.3,57.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:58.2,58.38 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:63.2,64.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:65.3,66.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:68.2,71.41 4 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:72.3,74.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:76.2,76.45 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:77.3,79.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:81.2,82.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:83.3,85.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:86.2,86.43 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:92.2,93.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:94.3,95.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:97.2,98.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:99.3,101.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:103.2,104.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:105.3,107.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:108.2,108.38 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:115.2,115.30 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:116.3,117.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:119.2,120.28 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:121.3,122.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:123.2,123.63 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:124.3,125.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:126.2,127.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:128.2,128.30 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:129.3,130.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:131.2,131.29 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:132.3,133.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:135.2,135.32 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:136.3,141.1 6 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:142.3,142.44 6 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:143.4,144.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:146.2,146.11 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:151.2,152.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:153.3,154.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:156.2,157.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:158.3,160.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/bookings.go:161.2,161.36 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:17.2,18.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:19.3,20.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:22.2,31.1 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:33.2,33.56 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:36.3,37.17 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:38.4,40.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:41.3,41.25 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:44.2,44.63 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:45.3,48.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:50.2,51.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:52.3,54.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:56.2,58.4 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:63.2,64.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:65.3,66.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:68.2,69.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:70.3,72.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:74.2,75.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:76.3,78.1 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:79.2,79.37 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:84.2,85.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:86.3,87.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:89.2,90.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:91.3,93.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:94.2,94.39 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:99.2,100.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:101.3,102.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:104.2,105.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:106.3,108.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:109.2,109.39 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:113.2,113.11 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:115.3,115.14 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/flights.go:117.2,117.14 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:28.2,29.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:40.2,42.17 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:43.3,44.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:48.2,49.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:54.2,54.9 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:56.3,56.50 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:58.3,58.82 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:60.3,61.73 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:68.2,69.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:70.3,72.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:73.2,73.17 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:78.2,79.15 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:80.3,81.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:82.2,83.27 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:84.3,85.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:86.2,86.13 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:87.3,88.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:89.2,89.10 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/handlers.go:95.2,96.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:14.2,15.16 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:16.3,18.1 2 0
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:20.2,23.1 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:24.2,25.28 3 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:26.3,27.1 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:28.2,28.36 1 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:35.2,36.9 2 1
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:37.3,38.1 1 0
github.com/travelswitch/travelhub/backend/internal/handlers/session.go:40.2,43.38 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:36.2,51.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:52.3,53.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:54.2,56.15 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:63.2,64.21 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:65.3,66.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:67.4,68.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:69.3,69.34 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:71.2,71.25 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:81.2,81.30 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:82.3,83.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:85.2,86.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:87.3,88.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:89.2,90.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:91.2,101.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:102.2,103.35 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:104.3,105.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:106.2,106.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:107.3,108.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:110.2,111.23 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:112.3,113.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:115.2,117.79 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:118.3,119.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:121.2,127.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:129.2,130.43 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:131.3,132.18 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:133.4,134.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:135.3,139.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:140.4,140.9 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:142.3,142.30 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:143.4,144.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:146.2,146.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:147.3,148.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:150.2,154.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:155.2,155.35 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:156.3,157.26 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:158.4,158.76 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:159.5,160.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:162.3,163.64 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:164.4,165.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:168.2,168.39 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:169.3,170.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:172.2,172.48 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:177.2,178.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:179.2,180.35 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:181.3,182.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:183.2,183.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:184.3,185.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:187.2,188.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:189.3,190.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:191.2,191.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:192.3,193.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:194.2,194.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:201.2,203.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:204.3,206.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:208.2,209.109 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:210.3,211.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:213.2,213.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:214.3,215.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:216.2,216.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:217.3,218.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:220.2,223.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:224.2,225.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:226.3,227.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:228.2,229.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:230.2,232.18 4 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:233.3,234.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:235.4,236.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:237.3,238.26 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:240.2,240.35 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:241.3,242.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:244.2,244.18 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:245.3,246.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:247.4,248.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:249.3,249.27 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:250.4,250.43 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:251.5,252.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:256.2,256.29 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:261.2,262.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:263.3,264.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:265.2,266.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:267.2,271.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:274.2,278.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:279.2,280.35 5 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:281.3,282.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:283.2,283.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:284.3,285.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:287.2,291.79 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:292.3,293.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:295.2,295.39 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:296.3,297.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:298.2,298.48 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:304.2,310.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:311.2,312.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:313.3,314.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:315.2,316.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:317.2,318.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:319.3,324.66 2 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:325.4,326.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:327.3,327.45 1 1
github.com/travelswitch/travelhub/backend/internal/repository/booking_repo.go:329.2,329.24 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:30.2,41.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:42.3,43.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:44.2,45.15 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:52.2,54.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:55.2,55.40 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:56.3,58.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:62.2,62.20 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:63.3,64.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:65.2,65.25 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:66.3,67.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:68.2,68.24 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:69.3,70.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:71.2,71.22 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:72.3,73.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:74.2,74.25 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:76.3,80.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:82.2,82.20 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:88.2,88.16 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:90.3,90.78 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:92.3,92.59 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:94.3,94.59 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:101.2,103.1 5 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:104.2,106.77 5 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:107.3,108.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:110.2,111.31 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:112.3,113.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:114.2,115.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:116.3,117.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:119.2,122.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:123.2,124.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:125.3,126.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:127.2,128.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:129.2,130.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:131.3,132.17 2 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:133.4,134.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:135.3,135.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:137.2,137.35 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:142.2,143.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:144.2,145.35 3 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:146.3,147.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/flight_repo.go:148.2,148.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:15.2,19.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:20.2,23.35 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:24.3,25.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:26.2,26.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:33.2,38.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:39.2,40.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:41.3,42.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:43.2,44.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:45.2,46.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:47.3,48.85 2 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:49.4,50.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:51.3,51.31 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:53.2,53.28 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:59.2,63.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:64.2,68.35 4 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:69.3,70.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/identity_repo.go:71.2,71.15 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:14.2,23.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:24.2,25.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:26.3,27.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:28.2,29.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:30.2,31.18 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:32.3,33.98 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:34.4,35.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:36.3,36.33 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:38.2,38.29 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:43.2,48.1 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:49.2,50.16 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:51.3,52.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:53.2,54.1 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:55.2,56.18 3 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:57.3,58.76 2 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:59.4,60.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:61.3,61.33 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:63.2,63.29 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:68.2,70.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:71.2,79.1 4 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:80.2,83.16 4 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:84.3,85.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:87.2,90.89 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:91.3,92.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:94.2,106.1 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:107.2,108.16 3 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:109.3,110.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:111.2,112.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:113.2,113.18 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:114.3,118.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:119.3,119.82 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:120.4,121.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:122.3,122.43 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:124.2,124.35 1 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:125.3,126.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:128.2,129.16 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:130.3,131.1 1 0
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:132.2,133.1 2 1
github.com/travelswitch/travelhub/backend/internal/repository/reference_repo.go:134.2,134.15 2 1
github.com/travelswitch/travelhub/backend/internal/repository/repository.go:31.2,32.1 1 1
github.com/travelswitch/travelhub/backend/internal/repository/repository.go:37.2,39.1 2 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:16.2,17.16 2 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:18.3,19.1 1 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:21.2,25.1 6 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:26.2,27.16 6 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:28.3,29.1 1 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:31.2,33.1 3 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:34.2,34.43 3 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:35.3,37.1 2 0
github.com/travelswitch/travelhub/backend/internal/database/database.go:39.2,39.18 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:32.2,33.1 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:37.2,38.9 2 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:39.3,40.1 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:41.2,41.16 1 0
github.com/travelswitch/travelhub/backend/internal/auth/auth.go:46.2,47.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:53.2,54.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:55.2,57.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:58.2,59.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:60.2,66.16 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:67.3,68.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:70.2,70.19 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:72.3,73.64 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:74.4,75.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:78.2,81.10 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:86.2,87.1 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:89.2,90.16 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:91.3,92.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:94.2,101.16 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:102.3,103.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:104.2,104.10 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:123.2,125.1 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:126.2,126.23 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:127.3,128.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:129.2,129.23 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:130.3,131.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:132.2,132.28 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:133.3,134.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:135.2,135.25 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:136.3,137.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:138.2,138.24 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:139.3,140.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:141.2,141.22 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:142.3,143.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:144.2,144.21 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:145.3,146.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:147.2,147.21 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:148.3,149.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:150.2,150.23 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:151.3,152.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:153.2,154.27 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:155.3,156.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:158.2,159.1 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:160.2,177.16 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:178.3,179.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:181.2,182.16 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:183.3,184.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:185.2,185.15 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:191.2,192.1 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:193.2,196.16 4 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:197.3,198.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:199.2,199.14 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:205.2,206.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:207.2,208.85 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:209.3,210.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:215.2,216.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:217.2,217.21 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:220.3,221.1 5 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:222.3,224.1 5 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:225.3,226.17 5 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:227.4,229.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:230.3,230.37 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:231.4,233.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:234.3,234.45 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:235.4,237.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:238.3,238.47 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:239.4,241.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:242.3,242.11 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:245.2,245.20 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:246.3,249.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:250.2,250.13 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:257.2,257.54 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:258.3,259.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:261.2,266.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:267.2,273.1 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:274.2,274.19 7 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:278.2,278.34 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:279.3,280.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:281.2,281.17 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:286.2,288.16 3 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:289.3,290.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:293.2,293.52 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:294.3,295.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:296.2,296.12 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:301.2,313.1 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:314.2,314.44 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:315.3,316.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:317.2,317.12 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:323.2,324.9 2 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:325.3,326.1 1 0
github.com/travelswitch/travelhub/backend/internal/testsupport/testsupport.go:328.2,328.59 1 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:29.2,30.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:31.2,32.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:33.2,37.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:38.2,42.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:43.2,44.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:45.2,49.1 16 0
github.com/travelswitch/travelhub/backend/internal/httpapi/router.go:50.2,55.1 16 0
+17
View File
@@ -0,0 +1,17 @@
module github.com/travelswitch/travelhub/backend
go 1.26.0
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.11.0
golang.org/x/crypto v0.57.0
)
require (
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
golang.org/x/sync v0.23.0 // indirect
golang.org/x/text v0.42.0 // indirect
)
+30
View File
@@ -0,0 +1,30 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.11.0 h1:IzBBtyK9AHqf98cctWFifYSci2hgQR/cd56wB4p+ogg=
github.com/jackc/pgx/v5 v5.11.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
golang.org/x/crypto v0.57.0 h1:3ZVCjf8Ggz7zneR/EHRVx68Ctf+2pmIMP2UFhh9cC6M=
golang.org/x/crypto v0.57.0/go.mod h1:Fdz0i5U6CoizGwLda9DttjSk6qlZo25zYNtR+ycvuZA=
golang.org/x/sync v0.23.0 h1:KameEIfc1IkluZyXWLn39Wd4tURc6GbCiISGiZm2bQk=
golang.org/x/sync v0.23.0/go.mod h1:sUUOizhqBxiL6pEWpqNLUiaJn1ShEbZ6BBqskPbjZm0=
golang.org/x/text v0.42.0 h1:JbOZXgfeCPU9gacVtYliJqOhD+zhrEqK4LfdpmlUZqI=
golang.org/x/text v0.42.0/go.mod h1:ojzP1Z+2QtioaF8DTtO8K5q7JWVVYwZKenzujK0Zd0E=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+47
View File
@@ -0,0 +1,47 @@
// Package auth carries the authenticated caller through the request context.
//
// Every tenant-scoped query in this service takes its tenant id from the
// Identity stored here, never from anything the client can name directly in a
// query string or body. That is the single rule that keeps tenants isolated.
package auth
import (
"context"
"errors"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// Identity is the authenticated user together with the tenant they signed into.
type Identity struct {
User models.User
Tenant models.Tenant
}
// contextKey is unexported so no other package can collide with our key.
type contextKey struct{}
var identityKey contextKey
// ErrNoIdentity means the handler ran without authentication middleware in
// front of it, which is a programming error rather than a client error.
var ErrNoIdentity = errors.New("no identity in request context")
// WithIdentity returns a context carrying the identity.
func WithIdentity(ctx context.Context, id Identity) context.Context {
return context.WithValue(ctx, identityKey, id)
}
// FromContext retrieves the identity placed by the auth middleware.
func FromContext(ctx context.Context) (Identity, error) {
id, ok := ctx.Value(identityKey).(Identity)
if !ok {
return Identity{}, ErrNoIdentity
}
return id, nil
}
// IsAdmin reports whether the caller holds the tenant admin role.
func (i Identity) IsAdmin() bool {
return i.User.Role == "admin"
}
+136
View File
@@ -0,0 +1,136 @@
// Package config loads runtime settings from the environment, optionally
// seeded from a .env file sitting next to the binary or at the module root.
package config
import (
"bufio"
"fmt"
"net"
"net/url"
"os"
"path/filepath"
"strings"
)
// Config holds everything the API needs to start.
type Config struct {
DatabaseURL string
Port string
CORSOrigins []string
}
// Load reads .env (if present) into the process environment, then builds a
// Config. Real environment variables always win over .env entries, so
// container and CI overrides behave the way people expect.
func Load() (*Config, error) {
LoadDotEnv()
cfg := &Config{
Port: envOr("PORT", "8080"),
CORSOrigins: splitAndTrim(envOr("CORS_ORIGINS", "http://localhost:4200")),
}
if raw := os.Getenv("DATABASE_URL"); raw != "" {
cfg.DatabaseURL = raw
} else {
user := envOr("DB_USER", "postgres")
pass := os.Getenv("DB_PASSWORD")
host := envOr("DB_HOST", "localhost")
port := envOr("DB_PORT", "5432")
name := envOr("DB_NAME", "travelhub")
ssl := envOr("DB_SSLMODE", "disable")
if pass == "" {
return nil, fmt.Errorf("database password is not set: define DATABASE_URL or DB_PASSWORD (see .env.example)")
}
// url.UserPassword escapes credentials for us, which matters because
// passwords routinely contain @ : / and other reserved characters.
cfg.DatabaseURL = (&url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: net.JoinHostPort(host, port),
Path: "/" + name,
RawQuery: "sslmode=" + ssl,
}).String()
}
return cfg, nil
}
// LoadDotEnv walks up from the working directory looking for a .env file and
// applies any keys that are not already set in the environment. Load calls it;
// it is exported so tests can pick up the same local credentials without
// building a whole Config.
func LoadDotEnv() {
dir, err := os.Getwd()
if err != nil {
return
}
for i := 0; i < 4; i++ {
path := filepath.Join(dir, ".env")
if applyEnvFile(path) {
return
}
parent := filepath.Dir(dir)
if parent == dir {
return
}
dir = parent
}
}
// applyEnvFile reads one .env file. It reports whether the file existed.
func applyEnvFile(path string) bool {
f, err := os.Open(path)
if err != nil {
return false
}
defer f.Close()
scanner := bufio.NewScanner(f)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
// Allow quoted values so passwords may contain leading/trailing spaces.
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '\'' && value[len(value)-1] == '\'') {
value = value[1 : len(value)-1]
}
}
if _, exists := os.LookupEnv(key); !exists {
os.Setenv(key, value)
}
}
return true
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
func splitAndTrim(s string) []string {
parts := strings.Split(s, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if p = strings.TrimSpace(p); p != "" {
out = append(out, p)
}
}
return out
}
+217
View File
@@ -0,0 +1,217 @@
package config_test
import (
"os"
"path/filepath"
"strings"
"testing"
"github.com/jackc/pgx/v5/pgxpool"
"github.com/travelswitch/travelhub/backend/internal/config"
)
// isolate points the process at an empty directory and clears every variable
// Load reads, so one test cannot inherit another's environment -- or the
// developer's real .env.
func isolate(t *testing.T) {
t.Helper()
for _, key := range []string{
"DATABASE_URL", "DB_HOST", "DB_PORT", "DB_USER", "DB_PASSWORD",
"DB_NAME", "DB_SSLMODE", "PORT", "CORS_ORIGINS",
} {
t.Setenv(key, "")
os.Unsetenv(key)
}
t.Chdir(t.TempDir())
}
func TestLoadDefaults(t *testing.T) {
isolate(t)
t.Setenv("DB_PASSWORD", "secret")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "8080" {
t.Errorf("Port = %q, want 8080", cfg.Port)
}
if len(cfg.CORSOrigins) != 1 || cfg.CORSOrigins[0] != "http://localhost:4200" {
t.Errorf("CORSOrigins = %v, want the Angular dev server", cfg.CORSOrigins)
}
if !strings.Contains(cfg.DatabaseURL, "/travelhub") {
t.Errorf("DatabaseURL = %q, want the default database name", cfg.DatabaseURL)
}
}
func TestLoadRequiresAPassword(t *testing.T) {
isolate(t)
_, err := config.Load()
if err == nil {
t.Fatal("want an error when neither DATABASE_URL nor DB_PASSWORD is set")
}
if !strings.Contains(err.Error(), "DB_PASSWORD") {
t.Errorf("error should name the missing variable, got: %v", err)
}
}
// The credentials in use here contain '@', which is exactly the character that
// breaks naive string concatenation into a DSN.
func TestLoadEscapesAwkwardPasswords(t *testing.T) {
isolate(t)
awkward := []string{
// Synthetic values only — never a real, in-use password (see the VAPT
// report, FINDING-8). Each still exercises a reserved DSN character.
"S3cr3t@x",
"p@ss:word/with?reserved#chars",
"has spaces and %25 percent",
"trailing@@@",
}
for _, password := range awkward {
t.Run(password, func(t *testing.T) {
t.Setenv("DB_PASSWORD", password)
t.Setenv("DB_USER", "postgres")
t.Setenv("DB_NAME", "travelhub")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
// The real proof is that the driver can parse it back out.
parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
t.Fatalf("pgx could not parse %q: %v", cfg.DatabaseURL, err)
}
if parsed.ConnConfig.Password != password {
t.Errorf("round-tripped password = %q, want %q",
parsed.ConnConfig.Password, password)
}
if parsed.ConnConfig.Database != "travelhub" {
t.Errorf("database = %q, want travelhub", parsed.ConnConfig.Database)
}
})
}
}
func TestDatabaseURLWinsOverParts(t *testing.T) {
isolate(t)
const explicit = "postgres://someone:else@db.internal:6543/other?sslmode=require"
t.Setenv("DATABASE_URL", explicit)
t.Setenv("DB_PASSWORD", "ignored")
t.Setenv("DB_NAME", "ignored")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.DatabaseURL != explicit {
t.Errorf("DatabaseURL = %q, want the explicit value", cfg.DatabaseURL)
}
}
func TestLoadReadsDotEnv(t *testing.T) {
isolate(t)
dir := t.TempDir()
contents := strings.Join([]string{
"# a comment",
"",
"DB_PASSWORD=from-file",
"DB_NAME=from-file-db",
`DB_USER="quoted-user"`,
"PORT=9999",
"CORS_ORIGINS=http://a.test, http://b.test ,",
"MALFORMED-LINE-WITHOUT-EQUALS",
}, "\n")
if err := os.WriteFile(filepath.Join(dir, ".env"), []byte(contents), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "9999" {
t.Errorf("Port = %q, want 9999 from .env", cfg.Port)
}
if got := cfg.CORSOrigins; len(got) != 2 || got[0] != "http://a.test" || got[1] != "http://b.test" {
t.Errorf("CORSOrigins = %v, want two trimmed origins", got)
}
parsed, err := pgxpool.ParseConfig(cfg.DatabaseURL)
if err != nil {
t.Fatalf("parse: %v", err)
}
if parsed.ConnConfig.Password != "from-file" {
t.Errorf("password = %q, want from-file", parsed.ConnConfig.Password)
}
if parsed.ConnConfig.Database != "from-file-db" {
t.Errorf("database = %q, want from-file-db", parsed.ConnConfig.Database)
}
// Surrounding quotes are stripped rather than taken literally.
if parsed.ConnConfig.User != "quoted-user" {
t.Errorf("user = %q, want quoted-user", parsed.ConnConfig.User)
}
}
// Real environment variables must beat .env, so container and CI overrides work.
func TestEnvironmentBeatsDotEnv(t *testing.T) {
isolate(t)
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, ".env"),
[]byte("DB_PASSWORD=from-file\nPORT=1111\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
t.Chdir(dir)
t.Setenv("PORT", "2222")
t.Setenv("DB_PASSWORD", "from-environment")
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
if cfg.Port != "2222" {
t.Errorf("Port = %q, want the environment value 2222", cfg.Port)
}
parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL)
if parsed.ConnConfig.Password != "from-environment" {
t.Errorf("password = %q, want from-environment", parsed.ConnConfig.Password)
}
}
func TestLoadFindsDotEnvInAParentDirectory(t *testing.T) {
isolate(t)
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, ".env"),
[]byte("DB_PASSWORD=parent-secret\n"), 0o600); err != nil {
t.Fatalf("write .env: %v", err)
}
nested := filepath.Join(root, "cmd", "api")
if err := os.MkdirAll(nested, 0o755); err != nil {
t.Fatalf("mkdir: %v", err)
}
t.Chdir(nested)
cfg, err := config.Load()
if err != nil {
t.Fatalf("load: %v", err)
}
parsed, _ := pgxpool.ParseConfig(cfg.DatabaseURL)
if parsed.ConnConfig.Password != "parent-secret" {
t.Errorf("password = %q, want parent-secret", parsed.ConnConfig.Password)
}
}
+40
View File
@@ -0,0 +1,40 @@
// Package database owns the PostgreSQL connection pool.
package database
import (
"context"
"fmt"
"time"
"github.com/jackc/pgx/v5/pgxpool"
)
// Connect opens a pooled connection and verifies it with a ping, so a bad
// password or an unreachable server fails at startup rather than on the first
// request.
func Connect(ctx context.Context, databaseURL string) (*pgxpool.Pool, error) {
cfg, err := pgxpool.ParseConfig(databaseURL)
if err != nil {
return nil, fmt.Errorf("parse database url: %w", err)
}
cfg.MaxConns = 10
cfg.MinConns = 1
cfg.MaxConnLifetime = time.Hour
cfg.MaxConnIdleTime = 30 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
if err != nil {
return nil, fmt.Errorf("create pool: %w", err)
}
pingCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel()
if err := pool.Ping(pingCtx); err != nil {
pool.Close()
return nil, fmt.Errorf("ping database: %w", err)
}
return pool, nil
}
+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)
}
+118
View File
@@ -0,0 +1,118 @@
package handlers
import (
"net/http"
"strings"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// SearchFlights handles GET /api/flights.
//
// Query parameters: origin, destination (IATA codes), date (YYYY-MM-DD),
// cabin, passengers, sort (price|duration|departure), limit, offset.
func (h *Handler) SearchFlights(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
q := r.URL.Query()
search := models.FlightSearch{
Origin: strings.ToUpper(strings.TrimSpace(q.Get("origin"))),
Destination: strings.ToUpper(strings.TrimSpace(q.Get("destination"))),
CabinClass: strings.TrimSpace(q.Get("cabin")),
Passengers: intParam(r, "passengers", 1, 1, 9),
SortBy: q.Get("sort"),
Limit: intParam(r, "limit", 25, 1, 100),
Offset: intParam(r, "offset", 0, 0, 100000),
}
if raw := strings.TrimSpace(q.Get("date")); raw != "" {
// Parsed in the server's local zone, which is what an agent selling
// from a desk in that zone means by "flights on the 12th".
d, err := time.ParseInLocation("2006-01-02", raw, time.Local)
if err != nil {
writeError(w, http.StatusBadRequest, "date must be in YYYY-MM-DD format")
return
}
search.DepartDate = &d
}
if search.CabinClass != "" && !validCabin(search.CabinClass) {
writeError(w, http.StatusBadRequest,
"cabin must be one of economy, premium_economy, business, first")
return
}
flights, total, err := h.Repo.SearchFlights(r.Context(), id.Tenant.ID, search)
if err != nil {
h.fail(w, err, "search flights")
return
}
writeJSON(w, http.StatusOK, page[models.Flight]{
Items: flights, Total: total, Limit: search.Limit, Offset: search.Offset,
})
}
// GetFlight handles GET /api/flights/{id}.
func (h *Handler) GetFlight(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
flightID, err := uuid.Parse(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid flight id")
return
}
flight, err := h.Repo.FlightByID(r.Context(), id.Tenant.ID, flightID)
if err != nil {
h.fail(w, err, "get flight")
return
}
writeJSON(w, http.StatusOK, flight)
}
// ListAirports handles GET /api/airports.
func (h *Handler) ListAirports(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airports, err := h.Repo.ListAirports(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airports")
return
}
writeJSON(w, http.StatusOK, airports)
}
// ListAirlines handles GET /api/airlines.
func (h *Handler) ListAirlines(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
airlines, err := h.Repo.ListAirlines(r.Context(), id.Tenant.ID)
if err != nil {
h.fail(w, err, "list airlines")
return
}
writeJSON(w, http.StatusOK, airlines)
}
func validCabin(c string) bool {
switch c {
case "economy", "premium_economy", "business", "first":
return true
}
return false
}
+96
View File
@@ -0,0 +1,96 @@
// Package handlers implements the TravelHub JSON API.
//
// Handlers never read a tenant id from the URL, query string, or body. They
// take it from the authenticated identity, which the Basic auth middleware
// resolved from the credentials. That keeps "which tenant am I acting as"
// impossible for a client to influence.
package handlers
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Handler carries the dependencies shared by every endpoint.
type Handler struct {
Repo *repository.Repository
Logger *slog.Logger
}
// New builds a Handler.
func New(repo *repository.Repository, logger *slog.Logger) *Handler {
return &Handler{Repo: repo, Logger: logger}
}
// page is the envelope for every list endpoint.
type page[T any] struct {
Items []T `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}
func writeJSON(w http.ResponseWriter, status int, body any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if body != nil {
json.NewEncoder(w).Encode(body)
}
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
// fail maps a repository error onto a status code, logging only the ones that
// represent a server fault.
func (h *Handler) fail(w http.ResponseWriter, err error, context string) {
switch {
case errors.Is(err, repository.ErrNotFound):
writeError(w, http.StatusNotFound, "not found")
case errors.Is(err, repository.ErrNoSeats):
writeError(w, http.StatusConflict, "not enough seats available on this flight")
default:
h.Logger.Error(context, "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}
// identity pulls the authenticated caller out of the request, replying 401 if
// the route was somehow mounted without the auth middleware.
func (h *Handler) identity(w http.ResponseWriter, r *http.Request) (auth.Identity, bool) {
id, err := auth.FromContext(r.Context())
if err != nil {
writeError(w, http.StatusUnauthorized, "authentication required")
return auth.Identity{}, false
}
return id, true
}
// intParam reads a bounded integer query parameter.
func intParam(r *http.Request, name string, fallback, min, max int) int {
raw := r.URL.Query().Get(name)
if raw == "" {
return fallback
}
v, err := strconv.Atoi(raw)
if err != nil || v < min {
return fallback
}
if v > max {
return max
}
return v
}
// Health reports liveness. It is the only endpoint that needs neither
// credentials nor a tenant.
func (h *Handler) Health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
+511
View File
@@ -0,0 +1,511 @@
package handlers_test
import (
"bytes"
"encoding/base64"
"encoding/json"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/httpapi"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// api spins the real router over a per-test tenant, so these exercise the same
// routing, middleware order and auth rules the server runs.
type api struct {
*testsupport.Fixture
handler http.Handler
}
func newAPI(t *testing.T) *api {
t.Helper()
f := testsupport.Setup(t)
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
h := handlers.New(f.Repo, logger)
return &api{
Fixture: f,
handler: httpapi.New(h, f.Repo, logger, []string{"http://localhost:4200"}),
}
}
// do issues an authenticated request as the fixture's agent.
func (a *api) do(t *testing.T, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
return a.as(t, a.Agent.Email, testsupport.Password, a.Tenant.Slug, method, path, body)
}
// anon issues a request with no credentials.
func (a *api) anon(t *testing.T, method, path string) *httptest.ResponseRecorder {
t.Helper()
req := httptest.NewRequest(method, path, nil)
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func (a *api) as(t *testing.T, email, password, tenant, method, path string, body any) *httptest.ResponseRecorder {
t.Helper()
var reader io.Reader
if body != nil {
raw, err := json.Marshal(body)
if err != nil {
t.Fatalf("marshal body: %v", err)
}
reader = bytes.NewReader(raw)
}
req := httptest.NewRequest(method, path, reader)
req.Header.Set("Authorization", "Basic "+
base64.StdEncoding.EncodeToString([]byte(email+":"+password)))
req.Header.Set(middleware.TenantHeader, tenant)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
return rec
}
func decode[T any](t *testing.T, rec *httptest.ResponseRecorder) T {
t.Helper()
var out T
if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil {
t.Fatalf("decode response %s: %v", rec.Body, err)
}
return out
}
func requireStatus(t *testing.T, rec *httptest.ResponseRecorder, want int) {
t.Helper()
if rec.Code != want {
t.Fatalf("status = %d, want %d (body: %s)", rec.Code, want, rec.Body)
}
}
// --- public endpoints ------------------------------------------------------
func TestHealthNeedsNoCredentials(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/health")
requireStatus(t, rec, http.StatusOK)
got := decode[map[string]string](t, rec)
if got["status"] != "ok" {
t.Errorf("body = %v, want status ok", got)
}
}
func TestTenantListIsPublicButThin(t *testing.T) {
a := newAPI(t)
rec := a.anon(t, http.MethodGet, "/api/tenants")
requireStatus(t, rec, http.StatusOK)
// The picker needs a slug and a name; it must not leak ids or timestamps.
got := decode[[]map[string]any](t, rec)
found := false
for _, tn := range got {
if tn["slug"] == a.Tenant.Slug {
found = true
if _, leaked := tn["id"]; leaked {
t.Error("tenant list leaked internal ids")
}
if len(tn) != 2 {
t.Errorf("tenant entry has %d fields, want just slug and name: %v", len(tn), tn)
}
}
}
if !found {
t.Error("the fixture's tenant is missing from the public list")
}
}
func TestProtectedRoutesRejectAnonymous(t *testing.T) {
a := newAPI(t)
for _, path := range []string{
"/api/session", "/api/flights", "/api/bookings", "/api/dashboard",
"/api/airports", "/api/airlines",
} {
t.Run(path, func(t *testing.T) {
// No credentials and no tenant: the tenant check fires first.
rec := a.anon(t, http.MethodGet, path)
if rec.Code != http.StatusBadRequest && rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 400 or 401", rec.Code)
}
})
}
}
// --- session ---------------------------------------------------------------
func TestSessionDescribesTheCaller(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}](t, rec)
if got.User.Email != a.Agent.Email {
t.Errorf("user = %s, want %s", got.User.Email, a.Agent.Email)
}
if got.Tenant.Slug != a.Tenant.Slug {
t.Errorf("tenant = %s, want %s", got.Tenant.Slug, a.Tenant.Slug)
}
}
func TestSessionNeverReturnsThePasswordHash(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/session", nil)
requireStatus(t, rec, http.StatusOK)
if bytes.Contains(rec.Body.Bytes(), []byte("password")) {
t.Errorf("session response mentions a password field: %s", rec.Body)
}
}
// --- flights ---------------------------------------------------------------
func TestSearchFlightsReturnsAPage(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
rec := a.do(t, http.MethodGet, "/api/flights?origin=JFK&destination=LHR", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[struct {
Items []models.Flight `json:"items"`
Total int `json:"total"`
Limit int `json:"limit"`
Offset int `json:"offset"`
}](t, rec)
if got.Total != 1 || len(got.Items) != 1 {
t.Fatalf("want one flight, got %d (total %d)", len(got.Items), got.Total)
}
if got.Limit != 25 {
t.Errorf("default limit = %d, want 25", got.Limit)
}
if got.Items[0].Airline.IATACode == "" || got.Items[0].Origin.City == "" {
t.Error("flight should arrive with its airline and airports expanded")
}
}
func TestSearchFlightsValidatesInput(t *testing.T) {
a := newAPI(t)
cases := []struct {
name string
path string
want int
}{
{"bad date", "/api/flights?date=12-09-2026", http.StatusBadRequest},
{"bad cabin", "/api/flights?cabin=luxury", http.StatusBadRequest},
{"good cabin", "/api/flights?cabin=business", http.StatusOK},
{"absurd limit is clamped", "/api/flights?limit=99999", http.StatusOK},
{"negative offset falls back", "/api/flights?offset=-5", http.StatusOK},
{"non-numeric passengers falls back", "/api/flights?passengers=lots", http.StatusOK},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodGet, tc.path, nil)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.want, rec.Body)
}
})
}
}
func TestGetFlightRejectsAMalformedID(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights/not-a-uuid", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestGetFlightAcrossTenantsIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{})
rec := a.do(t, http.MethodGet, "/api/flights/"+theirFlight.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
}
// --- bookings --------------------------------------------------------------
func newBooking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: "Test", LastName: "Traveller",
})
}
return req
}
func TestCreateBookingReturnsCreated(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 300})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
requireStatus(t, rec, http.StatusCreated)
got := decode[models.Booking](t, rec)
if got.TotalAmount != 600 {
t.Errorf("TotalAmount = %v, want 600", got.TotalAmount)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passengers, want 2", len(got.Passengers))
}
if left := a.SeatsAvailable(t, flight.ID); left != 18 {
t.Errorf("seats = %d, want 18", left)
}
}
func TestCreateBookingValidation(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
noName := newBooking(flight.ID, 1)
noName.Passengers[0].FirstName = " "
tooMany := newBooking(flight.ID, 10)
cases := []struct {
name string
body models.CreateBookingRequest
}{
{"missing flight id", models.CreateBookingRequest{
ContactEmail: "a@b.co",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"missing contact email", models.CreateBookingRequest{
FlightID: flight.ID,
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"malformed contact email", models.CreateBookingRequest{
FlightID: flight.ID,
ContactEmail: "definitely-not-an-email",
Passengers: []models.PassengerInput{{FirstName: "A", LastName: "B"}},
}},
{"no passengers", models.CreateBookingRequest{
FlightID: flight.ID, ContactEmail: "a@b.co",
}},
{"blank passenger name", noName},
{"more than nine passengers", tooMany},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
rec := a.do(t, http.MethodPost, "/api/bookings", tc.body)
if rec.Code != http.StatusBadRequest {
t.Errorf("status = %d, want 400 (body: %s)", rec.Code, rec.Body)
}
})
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("rejected bookings consumed seats: %d left, want 20", left)
}
}
func TestCreateBookingRejectsUnknownFields(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
// A client trying to name its own tenant must not be quietly ignored.
body := map[string]any{
"flightId": flight.ID,
"contactEmail": "ops@example.com",
"tenantId": uuid.New(),
"passengers": []map[string]string{{"firstName": "A", "lastName": "B"}},
}
rec := a.do(t, http.MethodPost, "/api/bookings", body)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingRejectsMalformedJSON(t *testing.T) {
a := newAPI(t)
req := httptest.NewRequest(http.MethodPost, "/api/bookings", bytes.NewReader([]byte("{not json")))
req.Header.Set("Authorization", "Basic "+base64.StdEncoding.EncodeToString(
[]byte(a.Agent.Email+":"+testsupport.Password)))
req.Header.Set(middleware.TenantHeader, a.Tenant.Slug)
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
a.handler.ServeHTTP(rec, req)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestCreateBookingOverCapacityIsConflict(t *testing.T) {
a := newAPI(t)
two := 2
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 3))
requireStatus(t, rec, http.StatusConflict)
}
func TestCreateBookingOnAnotherTenantsFlightIsFourOhFour(t *testing.T) {
a := newAPI(t)
other := testsupport.Setup(t)
theirFlight := other.NewFlight(t, testsupport.FlightOpts{Seats: 50})
rec := a.do(t, http.MethodPost, "/api/bookings", newBooking(theirFlight.ID, 1))
requireStatus(t, rec, http.StatusNotFound)
}
func TestBookingLifecycleOverHTTP(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 100})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2)))
fetched := decode[models.Booking](t, a.do(t, http.MethodGet, "/api/bookings/"+created.ID.String(), nil))
if fetched.BookingReference != created.BookingReference {
t.Errorf("fetched a different booking: %s vs %s", fetched.BookingReference, created.BookingReference)
}
listed := decode[struct {
Items []models.Booking `json:"items"`
Total int `json:"total"`
}](t, a.do(t, http.MethodGet, "/api/bookings", nil))
if listed.Total != 1 {
t.Errorf("list total = %d, want 1", listed.Total)
}
cancelled := decode[models.Booking](t,
a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil))
if cancelled.Status != "cancelled" {
t.Errorf("status = %q, want cancelled", cancelled.Status)
}
if left := a.SeatsAvailable(t, flight.ID); left != 20 {
t.Errorf("seats after cancel = %d, want 20", left)
}
// A second cancel finds nothing to cancel.
again := a.do(t, http.MethodPost, "/api/bookings/"+created.ID.String()+"/cancel", nil)
requireStatus(t, again, http.StatusNotFound)
}
func TestListBookingsRejectsAnUnknownStatus(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/bookings?status=pending", nil)
requireStatus(t, rec, http.StatusBadRequest)
}
func TestBookingsAreInvisibleToOtherTenants(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20})
created := decode[models.Booking](t, a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 1)))
// A second agency, with valid credentials of its own, must see nothing.
other := testsupport.Setup(t)
rec := a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings/"+created.ID.String(), nil)
requireStatus(t, rec, http.StatusNotFound)
list := decode[struct {
Total int `json:"total"`
}](t, a.as(t, other.Agent.Email, testsupport.Password, other.Tenant.Slug,
http.MethodGet, "/api/bookings", nil))
if list.Total != 0 {
t.Errorf("other tenant sees %d bookings, want 0", list.Total)
}
}
// --- dashboard and reference ----------------------------------------------
func TestDashboardSummarisesTheTenant(t *testing.T) {
a := newAPI(t)
flight := a.NewFlight(t, testsupport.FlightOpts{Seats: 20, Price: 250})
a.do(t, http.MethodPost, "/api/bookings", newBooking(flight.ID, 2))
rec := a.do(t, http.MethodGet, "/api/dashboard", nil)
requireStatus(t, rec, http.StatusOK)
got := decode[models.DashboardStats](t, rec)
if got.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", got.ConfirmedBookings)
}
if got.TotalRevenue != 500 {
t.Errorf("TotalRevenue = %v, want 500", got.TotalRevenue)
}
if len(got.RecentBookings) != 1 {
t.Errorf("RecentBookings = %d, want 1", len(got.RecentBookings))
}
}
func TestReferenceListsAreScopedToTheTenantNetwork(t *testing.T) {
a := newAPI(t)
a.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
airports := decode[[]models.Airport](t, a.do(t, http.MethodGet, "/api/airports", nil))
if len(airports) != 2 {
t.Errorf("got %d airports, want the 2 this tenant flies", len(airports))
}
airlines := decode[[]models.Airline](t, a.do(t, http.MethodGet, "/api/airlines", nil))
if len(airlines) != 1 {
t.Errorf("got %d airlines, want 1", len(airlines))
}
}
// --- routing ---------------------------------------------------------------
func TestUnknownRouteAndWrongMethod(t *testing.T) {
a := newAPI(t)
t.Run("unknown path", func(t *testing.T) {
rec := a.do(t, http.MethodGet, "/api/nonexistent", nil)
requireStatus(t, rec, http.StatusNotFound)
})
t.Run("wrong method", func(t *testing.T) {
rec := a.do(t, http.MethodDelete, "/api/flights", nil)
requireStatus(t, rec, http.StatusMethodNotAllowed)
})
}
func TestEveryJSONResponseCarriesItsContentType(t *testing.T) {
a := newAPI(t)
rec := a.do(t, http.MethodGet, "/api/flights", nil)
if got := rec.Header().Get("Content-Type"); got != "application/json" {
t.Errorf("Content-Type = %q, want application/json", got)
}
}
+44
View File
@@ -0,0 +1,44 @@
package handlers
import (
"net/http"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListTenants backs the agency picker on the sign-in screen. It is
// unauthenticated by necessity -- the client must choose a tenant before it
// can present credentials -- so it returns nothing but slugs and display
// names.
func (h *Handler) ListTenants(w http.ResponseWriter, r *http.Request) {
tenants, err := h.Repo.ListTenants(r.Context())
if err != nil {
h.fail(w, err, "list tenants")
return
}
type publicTenant struct {
Slug string `json:"slug"`
Name string `json:"name"`
}
items := make([]publicTenant, 0, len(tenants))
for _, t := range tenants {
items = append(items, publicTenant{Slug: t.Slug, Name: t.Name})
}
writeJSON(w, http.StatusOK, items)
}
// Session echoes the authenticated caller back to the client. The Angular app
// calls it once at sign-in to validate the credentials it just collected, and
// again on reload to confirm the stored ones still work.
func (h *Handler) Session(w http.ResponseWriter, r *http.Request) {
id, ok := h.identity(w, r)
if !ok {
return
}
writeJSON(w, http.StatusOK, struct {
User models.User `json:"user"`
Tenant models.Tenant `json:"tenant"`
}{User: id.User, Tenant: id.Tenant})
}
+55
View File
@@ -0,0 +1,55 @@
// Package httpapi assembles the HTTP handler tree.
//
// It lives outside package main so tests can drive the exact same routing,
// middleware order and auth rules the server runs in production, rather than a
// hand-rolled approximation of them.
package httpapi
import (
"log/slog"
"net/http"
"github.com/travelswitch/travelhub/backend/internal/handlers"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// New builds the full handler.
//
// Everything under /api/ requires Basic auth plus a tenant, except the two
// endpoints registered explicitly on the root mux: health, and the tenant list
// the sign-in screen needs before any credentials exist. More specific
// patterns win in Go's mux, so those two bypass the authenticated subtree.
func New(
h *handlers.Handler,
repo *repository.Repository,
logger *slog.Logger,
corsOrigins []string,
) http.Handler {
protected := http.NewServeMux()
protected.HandleFunc("GET /api/session", h.Session)
protected.HandleFunc("GET /api/flights", h.SearchFlights)
protected.HandleFunc("GET /api/flights/{id}", h.GetFlight)
protected.HandleFunc("GET /api/airports", h.ListAirports)
protected.HandleFunc("GET /api/airlines", h.ListAirlines)
protected.HandleFunc("GET /api/bookings", h.ListBookings)
protected.HandleFunc("POST /api/bookings", h.CreateBooking)
protected.HandleFunc("GET /api/bookings/{id}", h.GetBooking)
protected.HandleFunc("POST /api/bookings/{id}/cancel", h.CancelBooking)
protected.HandleFunc("GET /api/dashboard", h.Dashboard)
root := http.NewServeMux()
root.HandleFunc("GET /api/health", h.Health)
root.HandleFunc("GET /api/tenants", h.ListTenants)
root.Handle("/api/", middleware.BasicAuth(repo, logger)(protected))
return middleware.Chain(root,
middleware.Recoverer(logger),
middleware.Logger(logger),
middleware.CORS(corsOrigins),
)
}
+223
View File
@@ -0,0 +1,223 @@
// Package middleware contains the HTTP layer that runs before handlers:
// panic recovery, logging, CORS, and Basic authentication with tenant
// resolution.
package middleware
import (
"crypto/subtle"
"encoding/json"
"errors"
"log/slog"
"net/http"
"strings"
"time"
"golang.org/x/crypto/bcrypt"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// TenantHeader names the header an SPA uses to say which agency it is signing
// in to. A subdomain is used as a fallback so the same build works when
// deployed as skyline.example.com.
const TenantHeader = "X-Tenant"
// Recoverer turns a panic into a 500 instead of killing the connection.
func Recoverer(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic serving request",
"method", r.Method, "path", r.URL.Path, "panic", rec)
writeError(w, http.StatusInternalServerError, "internal server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
// statusRecorder remembers the status code so the logger can report it.
type statusRecorder struct {
http.ResponseWriter
status int
}
func (s *statusRecorder) WriteHeader(code int) {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
// Logger writes one line per request.
func Logger(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w, status: http.StatusOK}
next.ServeHTTP(rec, r)
logger.Info("request",
"method", r.Method,
"path", r.URL.Path,
"status", rec.status,
"tenant", r.Header.Get(TenantHeader),
"duration", time.Since(start).String(),
)
})
}
}
// CORS answers preflights and echoes back only origins on the allow list.
func CORS(allowed []string) func(http.Handler) http.Handler {
allowSet := make(map[string]bool, len(allowed))
for _, o := range allowed {
allowSet[strings.TrimSuffix(o, "/")] = true
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := strings.TrimSuffix(r.Header.Get("Origin"), "/")
if origin != "" && allowSet[origin] {
h := w.Header()
h.Set("Access-Control-Allow-Origin", origin)
h.Set("Access-Control-Allow-Credentials", "true")
h.Set("Access-Control-Allow-Headers", "Authorization, Content-Type, "+TenantHeader)
h.Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
h.Set("Access-Control-Max-Age", "600")
// Responses differ per origin, so caches must key on it.
h.Add("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
// BasicAuth authenticates the caller and pins them to one tenant.
//
// Both halves matter: the password is checked against the user row *inside*
// the named tenant, so identical credentials at another agency will not
// authenticate here. The resolved tenant goes into the request context and is
// the only tenant id the handlers below ever use.
func BasicAuth(repo *repository.Repository, logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
slug := tenantSlug(r)
if slug == "" {
writeError(w, http.StatusBadRequest,
"missing tenant: send the "+TenantHeader+" header")
return
}
email, password, ok := r.BasicAuth()
if !ok {
unauthorized(w)
return
}
ctx := r.Context()
tenant, err := repo.TenantBySlug(ctx, slug)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Same response as a bad password: do not let an
// unauthenticated caller enumerate which tenants exist.
unauthorized(w)
return
}
logger.Error("tenant lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
user, err := repo.UserByEmail(ctx, tenant.ID, email)
if err != nil {
if errors.Is(err, repository.ErrNotFound) {
// Hash a throwaway value so a missing user costs the same
// time as a wrong password, which stops an attacker
// learning valid addresses from response timing.
bcrypt.CompareHashAndPassword(dummyHash, []byte(password))
unauthorized(w)
return
}
logger.Error("user lookup failed", "error", err)
writeError(w, http.StatusInternalServerError, "internal server error")
return
}
if bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)) != nil {
unauthorized(w)
return
}
// Defence in depth: the user row must belong to the tenant we
// resolved. The query already guarantees it; this catches a future
// refactor that breaks the invariant.
if subtle.ConstantTimeCompare(user.TenantID[:], tenant.ID[:]) != 1 {
unauthorized(w)
return
}
ctx = auth.WithIdentity(ctx, auth.Identity{User: user, Tenant: tenant})
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
// RequireAdmin gates routes that only a tenant administrator may call.
func RequireAdmin(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
id, err := auth.FromContext(r.Context())
if err != nil || !id.IsAdmin() {
writeError(w, http.StatusForbidden, "administrator role required")
return
}
next.ServeHTTP(w, r)
})
}
// dummyHash is a valid bcrypt hash of a value nobody uses, for timing parity.
var dummyHash = []byte("$2a$10$N9qo8uLOickgx2ZMRZoMyeIjZAgcfl7p92ldGxad68LJZdL17lhWy")
// tenantSlug reads the tenant from the header, falling back to the first label
// of the Host name for subdomain-per-tenant deployments.
func tenantSlug(r *http.Request) string {
if v := strings.TrimSpace(r.Header.Get(TenantHeader)); v != "" {
return strings.ToLower(v)
}
host := r.Host
if h, _, found := strings.Cut(host, ":"); found {
host = h
}
labels := strings.Split(host, ".")
if len(labels) >= 3 && labels[0] != "www" {
return strings.ToLower(labels[0])
}
return ""
}
// unauthorized replies 401 without a WWW-Authenticate header on purpose: that
// header makes browsers open their own credential dialog, which would fight
// with the Angular sign-in screen.
func unauthorized(w http.ResponseWriter) {
writeError(w, http.StatusUnauthorized, "invalid credentials for this tenant")
}
func writeError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
json.NewEncoder(w).Encode(map[string]string{"error": message})
}
// Chain applies middleware so the first argument is the outermost layer.
func Chain(h http.Handler, mw ...func(http.Handler) http.Handler) http.Handler {
for i := len(mw) - 1; i >= 0; i-- {
h = mw[i](h)
}
return h
}
@@ -0,0 +1,342 @@
package middleware_test
import (
"encoding/base64"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/auth"
"github.com/travelswitch/travelhub/backend/internal/middleware"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, nil))
}
func basic(email, password string) string {
return "Basic " + base64.StdEncoding.EncodeToString([]byte(email+":"+password))
}
// guarded wraps a handler that records the identity the middleware resolved.
func guarded(f *testsupport.Fixture, seen *auth.Identity) http.Handler {
inner := http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if id, err := auth.FromContext(r.Context()); err == nil && seen != nil {
*seen = id
}
w.WriteHeader(http.StatusOK)
})
return middleware.BasicAuth(f.Repo, quietLogger())(inner)
}
func TestBasicAuthAcceptsValidCredentials(t *testing.T) {
f := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body: %s)", rec.Code, rec.Body)
}
if got.User.ID != f.Agent.ID {
t.Errorf("resolved user %s, want %s", got.User.ID, f.Agent.ID)
}
if got.Tenant.ID != f.Tenant.ID {
t.Errorf("resolved tenant %s, want %s", got.Tenant.ID, f.Tenant.ID)
}
}
func TestBasicAuthIsCaseInsensitiveOnEmail(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(strings.ToUpper(f.Agent.Email), testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200", rec.Code)
}
}
func TestBasicAuthRejections(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
cases := []struct {
name string
authHeader string
tenant string
wantStatus int
}{
{
name: "no credentials at all",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "wrong password",
authHeader: basic(f.Agent.Email, "not-the-password"),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown user",
authHeader: basic("nobody@nowhere.test", testsupport.Password),
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "unknown tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "no-such-agency",
wantStatus: http.StatusUnauthorized,
},
{
// The central rule: right password, wrong agency.
name: "valid credentials against another tenant",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: other.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
{
name: "missing tenant header",
authHeader: basic(f.Agent.Email, testsupport.Password),
tenant: "",
wantStatus: http.StatusBadRequest,
},
{
name: "malformed authorization header",
authHeader: "Bearer some.jwt.token",
tenant: f.Tenant.Slug,
wantStatus: http.StatusUnauthorized,
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
if tc.authHeader != "" {
req.Header.Set("Authorization", tc.authHeader)
}
if tc.tenant != "" {
req.Header.Set(middleware.TenantHeader, tc.tenant)
}
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != tc.wantStatus {
t.Errorf("status = %d, want %d (body: %s)", rec.Code, tc.wantStatus, rec.Body)
}
})
}
}
func TestBasicAuthRefusesSuspendedTenant(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusUnauthorized {
t.Errorf("status = %d, want 401 for a suspended tenant", rec.Code)
}
}
// Sending WWW-Authenticate would make the browser pop its own login dialog over
// the Angular sign-in screen, so its absence is a deliberate contract.
func TestUnauthorizedDoesNotTriggerTheBrowserDialog(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, "wrong"))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if got := rec.Header().Get("WWW-Authenticate"); got != "" {
t.Errorf("WWW-Authenticate = %q, want it absent", got)
}
if ct := rec.Header().Get("Content-Type"); ct != "application/json" {
t.Errorf("Content-Type = %q, want application/json", ct)
}
}
// Deployments that give each agency a subdomain need no client changes.
func TestTenantFallsBackToSubdomain(t *testing.T) {
f := testsupport.Setup(t)
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Host = f.Tenant.Slug + ".travelhub.example.com:8080"
rec := httptest.NewRecorder()
guarded(f, nil).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Errorf("status = %d, want 200 resolving the tenant from the subdomain", rec.Code)
}
}
func TestTenantHeaderBeatsSubdomain(t *testing.T) {
f := testsupport.Setup(t)
other := testsupport.Setup(t)
var got auth.Identity
req := httptest.NewRequest(http.MethodGet, "/api/session", nil)
req.Header.Set("Authorization", basic(f.Agent.Email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
req.Host = other.Tenant.Slug + ".travelhub.example.com"
rec := httptest.NewRecorder()
guarded(f, &got).ServeHTTP(rec, req)
if rec.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", rec.Code)
}
if got.Tenant.ID != f.Tenant.ID {
t.Error("the subdomain overrode the explicit tenant header")
}
}
func TestRequireAdmin(t *testing.T) {
f := testsupport.Setup(t)
adminOnly := middleware.BasicAuth(f.Repo, quietLogger())(
middleware.RequireAdmin(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})),
)
cases := []struct {
name string
email string
want int
}{
{"admin passes", f.Admin.Email, http.StatusOK},
{"agent is refused", f.Agent.Email, http.StatusForbidden},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/admin", nil)
req.Header.Set("Authorization", basic(tc.email, testsupport.Password))
req.Header.Set(middleware.TenantHeader, f.Tenant.Slug)
rec := httptest.NewRecorder()
adminOnly.ServeHTTP(rec, req)
if rec.Code != tc.want {
t.Errorf("status = %d, want %d", rec.Code, tc.want)
}
})
}
}
func TestCORS(t *testing.T) {
allowed := []string{"http://localhost:4200", "https://app.example.com/"}
ok := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
w.WriteHeader(http.StatusOK)
})
handler := middleware.CORS(allowed)(ok)
t.Run("echoes an allowed origin", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "http://localhost:4200" {
t.Errorf("Allow-Origin = %q", got)
}
if got := rec.Header().Get("Vary"); !strings.Contains(got, "Origin") {
t.Errorf("Vary = %q, want it to include Origin", got)
}
if got := rec.Header().Get("Access-Control-Allow-Headers"); !strings.Contains(got, middleware.TenantHeader) {
t.Errorf("Allow-Headers = %q, want it to include %s", got, middleware.TenantHeader)
}
})
t.Run("tolerates a trailing slash in configuration", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://app.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "https://app.example.com" {
t.Errorf("Allow-Origin = %q, want the origin echoed", got)
}
})
t.Run("ignores an origin that is not on the list", func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
req.Header.Set("Origin", "https://evil.example.com")
rec := httptest.NewRecorder()
handler.ServeHTTP(rec, req)
if got := rec.Header().Get("Access-Control-Allow-Origin"); got != "" {
t.Errorf("Allow-Origin = %q, want it absent", got)
}
})
t.Run("answers preflight without reaching the handler", func(t *testing.T) {
reached := false
h := middleware.CORS(allowed)(http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
reached = true
}))
req := httptest.NewRequest(http.MethodOptions, "/api/bookings", nil)
req.Header.Set("Origin", "http://localhost:4200")
rec := httptest.NewRecorder()
h.ServeHTTP(rec, req)
if rec.Code != http.StatusNoContent {
t.Errorf("status = %d, want 204", rec.Code)
}
if reached {
t.Error("preflight should short-circuit before the handler")
}
})
}
func TestRecovererTurnsPanicIntoFiveHundred(t *testing.T) {
boom := http.HandlerFunc(func(http.ResponseWriter, *http.Request) {
panic("something went very wrong")
})
handler := middleware.Recoverer(quietLogger())(boom)
req := httptest.NewRequest(http.MethodGet, "/api/flights", nil)
rec := httptest.NewRecorder()
// The point is that this call returns rather than taking the process down.
handler.ServeHTTP(rec, req)
if rec.Code != http.StatusInternalServerError {
t.Errorf("status = %d, want 500", rec.Code)
}
if !strings.Contains(rec.Body.String(), "internal server error") {
t.Errorf("body = %s, want a JSON error", rec.Body)
}
}
+143
View File
@@ -0,0 +1,143 @@
// Package models holds the API-facing representations of TravelHub data.
// JSON field names are camelCase so the Angular client can consume them
// without a translation layer.
package models
import (
"time"
"github.com/google/uuid"
)
// Tenant is one travel agency using the platform.
type Tenant struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Name string `json:"name"`
Status string `json:"status"`
CreatedAt time.Time `json:"createdAt"`
}
// User belongs to exactly one tenant. Email is unique per tenant, not globally,
// so the same person can hold accounts at several agencies.
type User struct {
ID uuid.UUID `json:"id"`
TenantID uuid.UUID `json:"tenantId"`
Email string `json:"email"`
FullName string `json:"fullName"`
Role string `json:"role"`
CreatedAt time.Time `json:"createdAt"`
// PasswordHash never leaves the server.
PasswordHash string `json:"-"`
}
// Airport is global reference data, shared by every tenant.
type Airport struct {
ID uuid.UUID `json:"id"`
IATACode string `json:"iataCode"`
Name string `json:"name"`
City string `json:"city"`
Country string `json:"country"`
Timezone string `json:"timezone"`
}
// Airline is global reference data, shared by every tenant.
type Airline struct {
ID uuid.UUID `json:"id"`
IATACode string `json:"iataCode"`
Name string `json:"name"`
LogoURL string `json:"logoUrl,omitempty"`
}
// Flight is tenant-scoped inventory: each agency loads its own fares, so the
// same physical route carries a different price per tenant.
type Flight struct {
ID uuid.UUID `json:"id"`
FlightNumber string `json:"flightNumber"`
Airline Airline `json:"airline"`
Origin Airport `json:"origin"`
Destination Airport `json:"destination"`
DepartureTime time.Time `json:"departureTime"`
ArrivalTime time.Time `json:"arrivalTime"`
DurationMinutes int `json:"durationMinutes"`
Aircraft string `json:"aircraft"`
CabinClass string `json:"cabinClass"`
BasePrice float64 `json:"basePrice"`
Currency string `json:"currency"`
SeatsTotal int `json:"seatsTotal"`
SeatsAvailable int `json:"seatsAvailable"`
Status string `json:"status"`
}
// Passenger is one traveller on a booking.
type Passenger struct {
ID uuid.UUID `json:"id"`
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfBirth *time.Time `json:"dateOfBirth,omitempty"`
PassportNumber string `json:"passportNumber,omitempty"`
SeatNumber string `json:"seatNumber,omitempty"`
}
// Booking is a confirmed sale against a flight, owned by one tenant.
type Booking struct {
ID uuid.UUID `json:"id"`
BookingReference string `json:"bookingReference"`
Flight Flight `json:"flight"`
Passengers []Passenger `json:"passengers"`
PassengerCount int `json:"passengerCount"`
TotalAmount float64 `json:"totalAmount"`
Currency string `json:"currency"`
Status string `json:"status"`
ContactEmail string `json:"contactEmail"`
ContactPhone string `json:"contactPhone,omitempty"`
BookedBy string `json:"bookedBy"`
CreatedAt time.Time `json:"createdAt"`
}
// FlightSearch carries the filters accepted by GET /api/flights.
type FlightSearch struct {
Origin string // IATA code, optional
Destination string // IATA code, optional
DepartDate *time.Time // matches the whole calendar day, optional
CabinClass string // optional
Passengers int // minimum seats that must still be available
SortBy string // price | duration | departure
Limit int
Offset int
}
// PassengerInput is one traveller as supplied by the client. Dates arrive as
// YYYY-MM-DD strings and are parsed when the booking is written.
type PassengerInput struct {
FirstName string `json:"firstName"`
LastName string `json:"lastName"`
DateOfBirth string `json:"dateOfBirth"`
PassportNumber string `json:"passportNumber"`
}
// CreateBookingRequest is the POST /api/bookings payload.
type CreateBookingRequest struct {
FlightID uuid.UUID `json:"flightId"`
ContactEmail string `json:"contactEmail"`
ContactPhone string `json:"contactPhone"`
Passengers []PassengerInput `json:"passengers"`
}
// DashboardStats powers the agency overview screen.
type DashboardStats struct {
TotalBookings int `json:"totalBookings"`
ConfirmedBookings int `json:"confirmedBookings"`
CancelledBookings int `json:"cancelledBookings"`
TotalRevenue float64 `json:"totalRevenue"`
Currency string `json:"currency"`
FlightsInStock int `json:"flightsInStock"`
SeatsSold int `json:"seatsSold"`
RecentBookings []Booking `json:"recentBookings"`
TopRoutes []struct {
Route string `json:"route"`
Bookings int `json:"bookings"`
Revenue float64 `json:"revenue"`
} `json:"topRoutes"`
}
+330
View File
@@ -0,0 +1,330 @@
package repository
import (
"context"
"crypto/rand"
"errors"
"fmt"
"math/big"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// bookingSelect projects a booking together with its flight and the agent who
// sold it.
const bookingSelect = `
SELECT b.id, b.booking_reference, b.passenger_count, b.total_amount,
b.currency, b.status, b.contact_email, b.contact_phone, b.created_at,
u.full_name,
f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
f.cabin_class, f.base_price, f.currency, f.seats_total,
f.seats_available, f.status,
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
FROM bookings b
JOIN users u ON u.id = b.user_id
JOIN flights f ON f.id = b.flight_id
JOIN airlines al ON al.id = f.airline_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id`
func scanBooking(row pgx.Row) (models.Booking, error) {
var b models.Booking
f := &b.Flight
err := row.Scan(
&b.ID, &b.BookingReference, &b.PassengerCount, &b.TotalAmount,
&b.Currency, &b.Status, &b.ContactEmail, &b.ContactPhone, &b.CreatedAt,
&b.BookedBy,
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
&f.SeatsAvailable, &f.Status,
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
&f.Origin.Country, &f.Origin.Timezone,
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
)
if err != nil {
return models.Booking{}, err
}
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
b.Passengers = []models.Passenger{}
return b, nil
}
// pnrAlphabet omits I, O, 0 and 1 because agents read references aloud.
const pnrAlphabet = "ABCDEFGHJKLMNPQRSTUVWXYZ23456789"
func newPNR() (string, error) {
out := make([]byte, 6)
for i := range out {
n, err := rand.Int(rand.Reader, big.NewInt(int64(len(pnrAlphabet))))
if err != nil {
return "", err
}
out[i] = pnrAlphabet[n.Int64()]
}
return string(out), nil
}
// CreateBooking sells seats on a flight.
//
// The whole thing runs in one transaction and takes a row lock on the flight
// (SELECT ... FOR UPDATE) before reading availability, so two agents booking
// the last seats at the same moment cannot oversell: the second waits for the
// first to commit and then sees the decremented count.
func (r *Repository) CreateBooking(ctx context.Context, tenantID, userID uuid.UUID, req models.CreateBookingRequest) (models.Booking, error) {
if len(req.Passengers) == 0 {
return models.Booking{}, errors.New("at least one passenger is required")
}
tx, err := r.pool.Begin(ctx)
if err != nil {
return models.Booking{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var (
price float64
currency string
available int
)
const lockSQL = `
SELECT base_price, currency, seats_available
FROM flights
WHERE tenant_id = $1 AND id = $2 AND status <> 'cancelled'
FOR UPDATE`
err = tx.QueryRow(ctx, lockSQL, tenantID, req.FlightID).Scan(&price, &currency, &available)
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, fmt.Errorf("lock flight: %w", err)
}
count := len(req.Passengers)
if available < count {
return models.Booking{}, ErrNoSeats
}
const decSQL = `UPDATE flights SET seats_available = seats_available - $3
WHERE tenant_id = $1 AND id = $2`
if _, err := tx.Exec(ctx, decSQL, tenantID, req.FlightID, count); err != nil {
return models.Booking{}, fmt.Errorf("reserve seats: %w", err)
}
const insertSQL = `
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
passenger_count, total_amount, currency,
contact_email, contact_phone)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id`
// The PNR is unique per tenant; on the rare collision, draw again.
var bookingID uuid.UUID
for attempt := 0; attempt < 5; attempt++ {
pnr, perr := newPNR()
if perr != nil {
return models.Booking{}, perr
}
err = tx.QueryRow(ctx, insertSQL,
tenantID, userID, req.FlightID, pnr, count,
price*float64(count), currency, req.ContactEmail, req.ContactPhone,
).Scan(&bookingID)
if err == nil {
break
}
if !isUniqueViolation(err) {
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
}
}
if err != nil {
return models.Booking{}, fmt.Errorf("insert booking: %w", err)
}
const paxSQL = `
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
date_of_birth, passport_number)
VALUES ($1, $2, $3, $4, $5, $6)`
for _, p := range req.Passengers {
var dob *time.Time
if p.DateOfBirth != "" {
if parsed, perr := time.Parse("2006-01-02", p.DateOfBirth); perr == nil {
dob = &parsed
}
}
if _, err := tx.Exec(ctx, paxSQL, tenantID, bookingID,
p.FirstName, p.LastName, dob, p.PassportNumber); err != nil {
return models.Booking{}, fmt.Errorf("insert passenger: %w", err)
}
}
if err := tx.Commit(ctx); err != nil {
return models.Booking{}, err
}
return r.BookingByID(ctx, tenantID, bookingID)
}
// BookingByID returns one booking with its passengers.
func (r *Repository) BookingByID(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
q := bookingSelect + ` WHERE b.tenant_id = $1 AND b.id = $2`
b, err := scanBooking(r.pool.QueryRow(ctx, q, tenantID, bookingID))
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, err
}
pax, err := r.passengersFor(ctx, tenantID, []uuid.UUID{b.ID})
if err != nil {
return models.Booking{}, err
}
if list, ok := pax[b.ID]; ok {
b.Passengers = list
}
return b, nil
}
// ListBookings returns a tenant's bookings, newest first, with passengers
// attached. Passengers are fetched in one extra query rather than one per
// booking.
func (r *Repository) ListBookings(ctx context.Context, tenantID uuid.UUID, status string, limit, offset int) ([]models.Booking, int, error) {
where := "WHERE b.tenant_id = $1"
args := []any{tenantID}
if status != "" {
args = append(args, status)
where += fmt.Sprintf(" AND b.status = $%d", len(args))
}
var total int
if err := r.pool.QueryRow(ctx, "SELECT count(*) FROM bookings b "+where, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count bookings: %w", err)
}
if limit <= 0 || limit > 100 {
limit = 25
}
if offset < 0 {
offset = 0
}
q := fmt.Sprintf("%s %s ORDER BY b.created_at DESC LIMIT $%d OFFSET $%d",
bookingSelect, where, len(args)+1, len(args)+2)
args = append(args, limit, offset)
rows, err := r.pool.Query(ctx, q, args...)
if err != nil {
return nil, 0, fmt.Errorf("list bookings: %w", err)
}
defer rows.Close()
bookings := []models.Booking{}
ids := []uuid.UUID{}
for rows.Next() {
b, err := scanBooking(rows)
if err != nil {
return nil, 0, err
}
bookings = append(bookings, b)
ids = append(ids, b.ID)
}
if err := rows.Err(); err != nil {
return nil, 0, err
}
if len(ids) > 0 {
pax, err := r.passengersFor(ctx, tenantID, ids)
if err != nil {
return nil, 0, err
}
for i := range bookings {
if list, ok := pax[bookings[i].ID]; ok {
bookings[i].Passengers = list
}
}
}
return bookings, total, nil
}
// CancelBooking voids a booking and returns its seats to inventory.
func (r *Repository) CancelBooking(ctx context.Context, tenantID, bookingID uuid.UUID) (models.Booking, error) {
tx, err := r.pool.Begin(ctx)
if err != nil {
return models.Booking{}, err
}
defer tx.Rollback(ctx) //nolint:errcheck // no-op once committed
var (
flightID uuid.UUID
count int
)
// Filtering on status here makes the cancel idempotent-safe: a second
// cancel matches no row and returns ErrNotFound rather than crediting the
// seats twice.
const claimSQL = `
UPDATE bookings SET status = 'cancelled'
WHERE tenant_id = $1 AND id = $2 AND status = 'confirmed'
RETURNING flight_id, passenger_count`
err = tx.QueryRow(ctx, claimSQL, tenantID, bookingID).Scan(&flightID, &count)
if errors.Is(err, pgx.ErrNoRows) {
return models.Booking{}, ErrNotFound
}
if err != nil {
return models.Booking{}, fmt.Errorf("cancel booking: %w", err)
}
const restoreSQL = `
UPDATE flights
SET seats_available = LEAST(seats_total, seats_available + $3)
WHERE tenant_id = $1 AND id = $2`
if _, err := tx.Exec(ctx, restoreSQL, tenantID, flightID, count); err != nil {
return models.Booking{}, fmt.Errorf("restore seats: %w", err)
}
if err := tx.Commit(ctx); err != nil {
return models.Booking{}, err
}
return r.BookingByID(ctx, tenantID, bookingID)
}
// passengersFor loads passengers for several bookings at once, keyed by
// booking id.
func (r *Repository) passengersFor(ctx context.Context, tenantID uuid.UUID, bookingIDs []uuid.UUID) (map[uuid.UUID][]models.Passenger, error) {
const q = `
SELECT booking_id, id, first_name, last_name, date_of_birth,
passport_number, seat_number
FROM passengers
WHERE tenant_id = $1 AND booking_id = ANY($2)
ORDER BY last_name, first_name`
rows, err := r.pool.Query(ctx, q, tenantID, bookingIDs)
if err != nil {
return nil, fmt.Errorf("list passengers: %w", err)
}
defer rows.Close()
out := map[uuid.UUID][]models.Passenger{}
for rows.Next() {
var (
bookingID uuid.UUID
p models.Passenger
)
if err := rows.Scan(&bookingID, &p.ID, &p.FirstName, &p.LastName,
&p.DateOfBirth, &p.PassportNumber, &p.SeatNumber); err != nil {
return nil, err
}
out[bookingID] = append(out[bookingID], p)
}
return out, rows.Err()
}
@@ -0,0 +1,415 @@
package repository_test
import (
"context"
"sync"
"testing"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
// booking builds a request for the given flight with n generated travellers.
func booking(flightID uuid.UUID, n int) models.CreateBookingRequest {
req := models.CreateBookingRequest{
FlightID: flightID,
ContactEmail: "ops@example.com",
ContactPhone: "+1-555-0100",
}
names := []string{"Ada", "Ben", "Cleo", "Dara", "Emil", "Fay", "Gus", "Hana", "Ivo"}
for i := 0; i < n; i++ {
req.Passengers = append(req.Passengers, models.PassengerInput{
FirstName: names[i%len(names)],
LastName: "Traveller",
DateOfBirth: "1990-01-15",
PassportNumber: "P000000" + string(rune('1'+i)),
})
}
return req
}
func TestCreateBookingSellsSeats(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 250})
got, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 2))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if got.PassengerCount != 2 {
t.Errorf("PassengerCount = %d, want 2", got.PassengerCount)
}
if got.TotalAmount != 500 {
t.Errorf("TotalAmount = %v, want 500 (250 x 2)", got.TotalAmount)
}
if got.Status != "confirmed" {
t.Errorf("Status = %q, want confirmed", got.Status)
}
if len(got.BookingReference) != 6 {
t.Errorf("booking reference %q should be 6 characters", got.BookingReference)
}
if len(got.Passengers) != 2 {
t.Errorf("got %d passenger rows, want 2", len(got.Passengers))
}
if got.BookedBy != f.Agent.FullName {
t.Errorf("BookedBy = %q, want %q", got.BookedBy, f.Agent.FullName)
}
if left := f.SeatsAvailable(t, flight.ID); left != 48 {
t.Errorf("seats_available = %d, want 48", left)
}
}
func TestCreateBookingStoresPassengerDetail(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
got, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create booking: %v", err)
}
p := got.Passengers[0]
if p.FirstName != "Ada" || p.LastName != "Traveller" {
t.Errorf("passenger name = %s %s, want Ada Traveller", p.FirstName, p.LastName)
}
if p.DateOfBirth == nil {
t.Fatal("date of birth was not parsed and stored")
}
if got := p.DateOfBirth.Format("2006-01-02"); got != "1990-01-15" {
t.Errorf("date of birth = %s, want 1990-01-15", got)
}
}
func TestCreateBookingRejectsBlankDateOfBirth(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
req := booking(flight.ID, 1)
req.Passengers[0].DateOfBirth = "" // optional field left empty by the UI
got, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, req)
if err != nil {
t.Fatalf("an empty date of birth should be accepted: %v", err)
}
if got.Passengers[0].DateOfBirth != nil {
t.Error("want nil date of birth when none was supplied")
}
}
func TestCreateBookingRefusesMoreSeatsThanRemain(t *testing.T) {
f := testsupport.Setup(t)
two := 2
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 10, Available: &two})
_, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 3))
if err != repository.ErrNoSeats {
t.Fatalf("want ErrNoSeats, got %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 2 {
t.Errorf("a refused booking must not consume seats: got %d, want 2", left)
}
}
func TestCreateBookingRequiresAPassenger(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{})
req := models.CreateBookingRequest{FlightID: flight.ID, ContactEmail: "a@b.co"}
if _, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, req); err == nil {
t.Fatal("want an error for a booking with no passengers")
}
}
func TestCreateBookingCannotReachAnotherTenantsFlight(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
other := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 100})
_, err := mine.Repo.CreateBooking(context.Background(), mine.Tenant.ID, mine.Agent.ID,
booking(other.ID, 1))
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound booking across tenants, got %v", err)
}
if left := theirs.SeatsAvailable(t, other.ID); left != 100 {
t.Errorf("the other tenant's inventory was touched: %d seats left, want 100", left)
}
}
func TestCreateBookingRefusesCancelledFlight(t *testing.T) {
f := testsupport.Setup(t)
flight := f.NewFlight(t, testsupport.FlightOpts{Status: "cancelled"})
_, err := f.Repo.CreateBooking(context.Background(), f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for a cancelled flight, got %v", err)
}
}
// The row lock is the only thing standing between two simultaneous sales and
// an oversold flight, so assert on it directly.
func TestCreateBookingDoesNotOversellUnderConcurrency(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
const contenders = 8
one := 1
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 20, Available: &one})
var (
wg sync.WaitGroup
mu sync.Mutex
sold int
refused int
unexpect []error
)
start := make(chan struct{})
for i := 0; i < contenders; i++ {
wg.Add(1)
go func() {
defer wg.Done()
<-start // release everyone at once
_, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
mu.Lock()
defer mu.Unlock()
switch {
case err == nil:
sold++
case err == repository.ErrNoSeats:
refused++
default:
unexpect = append(unexpect, err)
}
}()
}
close(start)
wg.Wait()
if len(unexpect) > 0 {
t.Fatalf("unexpected errors: %v", unexpect)
}
if sold != 1 {
t.Errorf("exactly one booking should succeed, got %d", sold)
}
if refused != contenders-1 {
t.Errorf("got %d refusals, want %d", refused, contenders-1)
}
if left := f.SeatsAvailable(t, flight.ID); left != 0 {
t.Errorf("seats_available = %d, want 0", left)
}
}
func TestCancelBookingReturnsSeats(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 30})
made, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 3))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 27 {
t.Fatalf("seats after booking = %d, want 27", left)
}
got, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID)
if err != nil {
t.Fatalf("cancel: %v", err)
}
if got.Status != "cancelled" {
t.Errorf("Status = %q, want cancelled", got.Status)
}
if left := f.SeatsAvailable(t, flight.ID); left != 30 {
t.Errorf("seats after cancel = %d, want 30", left)
}
}
// Cancelling twice must not credit the seats twice.
func TestCancelBookingIsNotRepeatable(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 30})
made, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 2))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if _, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID); err != nil {
t.Fatalf("first cancel: %v", err)
}
if _, err := f.Repo.CancelBooking(ctx, f.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("second cancel should be ErrNotFound, got %v", err)
}
if left := f.SeatsAvailable(t, flight.ID); left != 30 {
t.Errorf("seats = %d, want 30 -- a second cancel must not credit again", left)
}
}
func TestCancelBookingAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 10})
made, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create booking: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
still, err := theirs.Repo.BookingByID(ctx, theirs.Tenant.ID, made.ID)
if err != nil {
t.Fatalf("read back: %v", err)
}
if still.Status != "confirmed" {
t.Error("another tenant managed to cancel this booking")
}
}
func TestListBookingsIsolatesTenantsAndFiltersStatus(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := mine.NewFlight(t, testsupport.FlightOpts{Seats: 50})
keep, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
drop, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, drop.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
otherFlight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 50})
if _, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(otherFlight.ID, 1)); err != nil {
t.Fatalf("create for other tenant: %v", err)
}
all, total, err := mine.Repo.ListBookings(ctx, mine.Tenant.ID, "", 25, 0)
if err != nil {
t.Fatalf("list: %v", err)
}
if total != 2 || len(all) != 2 {
t.Fatalf("want this tenant's 2 bookings, got %d (total %d)", len(all), total)
}
confirmed, total, err := mine.Repo.ListBookings(ctx, mine.Tenant.ID, "confirmed", 25, 0)
if err != nil {
t.Fatalf("list confirmed: %v", err)
}
if total != 1 || len(confirmed) != 1 || confirmed[0].ID != keep.ID {
t.Fatalf("status filter did not isolate the confirmed booking (got %d)", len(confirmed))
}
if len(confirmed[0].Passengers) != 1 {
t.Error("list should attach passengers to each booking")
}
}
func TestBookingByIDAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := theirs.NewFlight(t, testsupport.FlightOpts{})
made, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.BookingByID(ctx, mine.Tenant.ID, made.ID); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestBookingReferencesAreUniqueWithinATenant(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
flight := f.NewFlight(t, testsupport.FlightOpts{Seats: 100})
seen := map[string]bool{}
for i := 0; i < 25; i++ {
got, err := f.Repo.CreateBooking(ctx, f.Tenant.ID, f.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create %d: %v", i, err)
}
if seen[got.BookingReference] {
t.Fatalf("duplicate booking reference %q", got.BookingReference)
}
seen[got.BookingReference] = true
}
}
func TestDashboardStatsCountOnlyThisTenant(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
flight := mine.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 200})
if _, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 2)); err != nil {
t.Fatalf("create: %v", err)
}
cancelled, err := mine.Repo.CreateBooking(ctx, mine.Tenant.ID, mine.Agent.ID, booking(flight.ID, 1))
if err != nil {
t.Fatalf("create: %v", err)
}
if _, err := mine.Repo.CancelBooking(ctx, mine.Tenant.ID, cancelled.ID); err != nil {
t.Fatalf("cancel: %v", err)
}
otherFlight := theirs.NewFlight(t, testsupport.FlightOpts{Seats: 50, Price: 999})
if _, err := theirs.Repo.CreateBooking(ctx, theirs.Tenant.ID, theirs.Agent.ID, booking(otherFlight.ID, 4)); err != nil {
t.Fatalf("create for other tenant: %v", err)
}
stats, err := mine.Repo.DashboardStats(ctx, mine.Tenant.ID)
if err != nil {
t.Fatalf("stats: %v", err)
}
if stats.TotalBookings != 2 {
t.Errorf("TotalBookings = %d, want 2", stats.TotalBookings)
}
if stats.ConfirmedBookings != 1 {
t.Errorf("ConfirmedBookings = %d, want 1", stats.ConfirmedBookings)
}
if stats.CancelledBookings != 1 {
t.Errorf("CancelledBookings = %d, want 1", stats.CancelledBookings)
}
// Revenue counts confirmed sales only: 200 x 2.
if stats.TotalRevenue != 400 {
t.Errorf("TotalRevenue = %v, want 400", stats.TotalRevenue)
}
if stats.SeatsSold != 2 {
t.Errorf("SeatsSold = %d, want 2", stats.SeatsSold)
}
if stats.FlightsInStock != 1 {
t.Errorf("FlightsInStock = %d, want 1", stats.FlightsInStock)
}
if len(stats.TopRoutes) != 1 || stats.TopRoutes[0].Revenue != 400 {
t.Errorf("TopRoutes did not summarise this tenant's sales: %+v", stats.TopRoutes)
}
}
+149
View File
@@ -0,0 +1,149 @@
package repository
import (
"context"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// flightSelect is the projection shared by every flight read, joining the two
// global reference tables so the API can return nested airline/airport objects.
const flightSelect = `
SELECT f.id, f.flight_number, f.departure_time, f.arrival_time, f.aircraft,
f.cabin_class, f.base_price, f.currency, f.seats_total,
f.seats_available, f.status,
al.id, al.iata_code, al.name, COALESCE(al.logo_url, ''),
o.id, o.iata_code, o.name, o.city, o.country, o.timezone,
d.id, d.iata_code, d.name, d.city, d.country, d.timezone
FROM flights f
JOIN airlines al ON al.id = f.airline_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id`
// scanFlight reads one row of flightSelect.
func scanFlight(row pgx.Row) (models.Flight, error) {
var f models.Flight
err := row.Scan(
&f.ID, &f.FlightNumber, &f.DepartureTime, &f.ArrivalTime, &f.Aircraft,
&f.CabinClass, &f.BasePrice, &f.Currency, &f.SeatsTotal,
&f.SeatsAvailable, &f.Status,
&f.Airline.ID, &f.Airline.IATACode, &f.Airline.Name, &f.Airline.LogoURL,
&f.Origin.ID, &f.Origin.IATACode, &f.Origin.Name, &f.Origin.City,
&f.Origin.Country, &f.Origin.Timezone,
&f.Destination.ID, &f.Destination.IATACode, &f.Destination.Name,
&f.Destination.City, &f.Destination.Country, &f.Destination.Timezone,
)
if err != nil {
return models.Flight{}, err
}
f.DurationMinutes = int(f.ArrivalTime.Sub(f.DepartureTime).Minutes())
return f, nil
}
// searchFilters turns a FlightSearch into WHERE fragments plus their arguments.
// tenantID is always $1, so every generated query is tenant-scoped by
// construction rather than by the caller remembering to add it.
func searchFilters(tenantID uuid.UUID, s models.FlightSearch) ([]string, []any) {
where := []string{"f.tenant_id = $1", "f.status <> 'cancelled'", "f.departure_time > now()"}
args := []any{tenantID}
add := func(clause string, value any) {
args = append(args, value)
where = append(where, fmt.Sprintf(clause, len(args)))
}
// Airports are matched by IATA code through a subquery so the count query
// does not need the joins that flightSelect carries.
if s.Origin != "" {
add("f.origin_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Origin)
}
if s.Destination != "" {
add("f.destination_airport_id = (SELECT id FROM airports WHERE iata_code = upper($%d))", s.Destination)
}
if s.CabinClass != "" {
add("f.cabin_class = $%d", s.CabinClass)
}
if s.Passengers > 0 {
add("f.seats_available >= $%d", s.Passengers)
}
if s.DepartDate != nil {
// Half-open range keeps the composite index on departure_time usable.
args = append(args, *s.DepartDate)
where = append(where, fmt.Sprintf(
"f.departure_time >= $%d AND f.departure_time < ($%d::timestamptz + interval '1 day')",
len(args), len(args)))
}
return where, args
}
// orderClause whitelists sort options; anything unrecognised falls back to
// price so a hostile sortBy value can never reach the SQL text.
func orderClause(sortBy string) string {
switch sortBy {
case "duration":
return "ORDER BY (f.arrival_time - f.departure_time) ASC, f.base_price ASC"
case "departure":
return "ORDER BY f.departure_time ASC, f.base_price ASC"
default:
return "ORDER BY f.base_price ASC, f.departure_time ASC"
}
}
// SearchFlights returns one page of a tenant's inventory plus the total count
// matching the filters.
func (r *Repository) SearchFlights(ctx context.Context, tenantID uuid.UUID, s models.FlightSearch) ([]models.Flight, int, error) {
where, args := searchFilters(tenantID, s)
whereSQL := "WHERE " + strings.Join(where, " AND ")
var total int
countSQL := "SELECT count(*) FROM flights f " + whereSQL
if err := r.pool.QueryRow(ctx, countSQL, args...).Scan(&total); err != nil {
return nil, 0, fmt.Errorf("count flights: %w", err)
}
limit := s.Limit
if limit <= 0 || limit > 100 {
limit = 25
}
offset := s.Offset
if offset < 0 {
offset = 0
}
pageArgs := append(append([]any{}, args...), limit, offset)
listSQL := fmt.Sprintf("%s %s %s LIMIT $%d OFFSET $%d",
flightSelect, whereSQL, orderClause(s.SortBy), len(args)+1, len(args)+2)
rows, err := r.pool.Query(ctx, listSQL, pageArgs...)
if err != nil {
return nil, 0, fmt.Errorf("list flights: %w", err)
}
defer rows.Close()
flights := []models.Flight{}
for rows.Next() {
f, err := scanFlight(rows)
if err != nil {
return nil, 0, err
}
flights = append(flights, f)
}
return flights, total, rows.Err()
}
// FlightByID fetches a single flight belonging to the tenant.
func (r *Repository) FlightByID(ctx context.Context, tenantID, flightID uuid.UUID) (models.Flight, error) {
q := flightSelect + ` WHERE f.tenant_id = $1 AND f.id = $2`
f, err := scanFlight(r.pool.QueryRow(ctx, q, tenantID, flightID))
if errors.Is(err, pgx.ErrNoRows) {
return models.Flight{}, ErrNotFound
}
return f, err
}
@@ -0,0 +1,280 @@
package repository_test
import (
"context"
"testing"
"time"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func TestSearchFlightsFiltersByRoute(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
want := f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
f.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"})
got, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{
Origin: "JFK", Destination: "LHR",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 1 || len(got) != 1 {
t.Fatalf("want 1 flight, got %d (total %d)", len(got), total)
}
if got[0].ID != want.ID {
t.Errorf("wrong flight: got %s want %s", got[0].ID, want.ID)
}
}
func TestSearchFlightsLowercaseIATAStillMatches(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
// The repository upper-cases codes in SQL, so a sloppy client still works.
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
Origin: "jfk", Destination: "lhr",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 {
t.Fatalf("want 1 flight for lowercase codes, got %d", len(got))
}
}
// The headline guarantee: one agency's inventory is invisible to another.
func TestSearchFlightsIsolatesTenants(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
ctx := context.Background()
mine.NewFlight(t, testsupport.FlightOpts{Number: "MINE1"})
theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS1"})
theirs.NewFlight(t, testsupport.FlightOpts{Number: "THEIRS2"})
got, total, err := mine.Repo.SearchFlights(ctx, mine.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 1 || len(got) != 1 {
t.Fatalf("tenant should see exactly its own 1 flight, saw %d (total %d)", len(got), total)
}
if got[0].FlightNumber != "MINE1" {
t.Errorf("leaked another tenant's flight: %s", got[0].FlightNumber)
}
}
func TestFlightByIDAcrossTenantsIsNotFound(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
other := theirs.NewFlight(t, testsupport.FlightOpts{})
// Even holding a valid id, the wrong tenant must get ErrNotFound -- not a
// permission error, which would confirm the record exists.
_, err := mine.Repo.FlightByID(context.Background(), mine.Tenant.ID, other.ID)
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for cross-tenant read, got %v", err)
}
}
func TestFlightByIDUnknownIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
_, err := f.Repo.FlightByID(context.Background(), f.Tenant.ID, uuid.New())
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
func TestSearchFlightsHidesDepartedAndCancelled(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Number: "FUTURE", DepartsIn: 24 * time.Hour})
f.NewFlight(t, testsupport.FlightOpts{Number: "PAST", DepartsIn: -24 * time.Hour})
f.NewFlight(t, testsupport.FlightOpts{Number: "GONE", Status: "cancelled"})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "FUTURE" {
names := make([]string, len(got))
for i, fl := range got {
names[i] = fl.FlightNumber
}
t.Fatalf("want only FUTURE, got %v", names)
}
}
func TestSearchFlightsRespectsPartySize(t *testing.T) {
f := testsupport.Setup(t)
two := 2
five := 5
f.NewFlight(t, testsupport.FlightOpts{Number: "TIGHT", Seats: 10, Available: &two})
f.NewFlight(t, testsupport.FlightOpts{Number: "ROOMY", Seats: 10, Available: &five})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
Passengers: 4,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "ROOMY" {
t.Fatalf("a party of 4 should only see ROOMY, got %d results", len(got))
}
}
func TestSearchFlightsFiltersByCabin(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Number: "ECON", Cabin: "economy"})
f.NewFlight(t, testsupport.FlightOpts{Number: "BIZ", Cabin: "business"})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
CabinClass: "business",
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "BIZ" {
t.Fatalf("want only the business flight, got %d results", len(got))
}
}
func TestSearchFlightsFiltersByDepartureDay(t *testing.T) {
f := testsupport.Setup(t)
// Land both flights at a fixed hour so the test cannot straddle midnight.
tomorrow := time.Now().AddDate(0, 0, 1)
target := time.Date(tomorrow.Year(), tomorrow.Month(), tomorrow.Day(), 9, 0, 0, 0, time.Local)
f.NewFlight(t, testsupport.FlightOpts{Number: "ONDAY", DepartsIn: time.Until(target)})
f.NewFlight(t, testsupport.FlightOpts{Number: "OFFDAY", DepartsIn: time.Until(target.AddDate(0, 0, 3))})
day := time.Date(target.Year(), target.Month(), target.Day(), 0, 0, 0, 0, time.Local)
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{
DepartDate: &day,
})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) != 1 || got[0].FlightNumber != "ONDAY" {
t.Fatalf("want only ONDAY for that calendar day, got %d results", len(got))
}
}
func TestSearchFlightsSorting(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
f.NewFlight(t, testsupport.FlightOpts{
Number: "CHEAPSLOW", Price: 100, Duration: 12 * time.Hour, DepartsIn: 72 * time.Hour,
})
f.NewFlight(t, testsupport.FlightOpts{
Number: "DEARFAST", Price: 900, Duration: 2 * time.Hour, DepartsIn: 24 * time.Hour,
})
cases := []struct {
sortBy string
want string
}{
{"price", "CHEAPSLOW"},
{"duration", "DEARFAST"},
{"departure", "DEARFAST"},
{"nonsense-falls-back-to-price", "CHEAPSLOW"},
}
for _, tc := range cases {
t.Run(tc.sortBy, func(t *testing.T) {
got, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{SortBy: tc.sortBy})
if err != nil {
t.Fatalf("search: %v", err)
}
if len(got) == 0 {
t.Fatal("no results")
}
if got[0].FlightNumber != tc.want {
t.Errorf("sort %q: first result %s, want %s", tc.sortBy, got[0].FlightNumber, tc.want)
}
})
}
}
func TestSearchFlightsPaginates(t *testing.T) {
f := testsupport.Setup(t)
ctx := context.Background()
for i := 0; i < 5; i++ {
f.NewFlight(t, testsupport.FlightOpts{Price: float64(100 * (i + 1))})
}
first, total, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 0})
if err != nil {
t.Fatalf("search: %v", err)
}
if total != 5 {
t.Errorf("total = %d, want 5", total)
}
if len(first) != 2 {
t.Fatalf("page size = %d, want 2", len(first))
}
second, _, err := f.Repo.SearchFlights(ctx, f.Tenant.ID, models.FlightSearch{Limit: 2, Offset: 2})
if err != nil {
t.Fatalf("search page 2: %v", err)
}
if len(second) != 2 {
t.Fatalf("second page size = %d, want 2", len(second))
}
if first[0].ID == second[0].ID {
t.Error("second page repeated the first page's first row")
}
}
func TestSearchFlightsComputesDuration(t *testing.T) {
f := testsupport.Setup(t)
f.NewFlight(t, testsupport.FlightOpts{Duration: 7*time.Hour + 25*time.Minute})
got, _, err := f.Repo.SearchFlights(context.Background(), f.Tenant.ID, models.FlightSearch{})
if err != nil {
t.Fatalf("search: %v", err)
}
if got[0].DurationMinutes != 445 {
t.Errorf("DurationMinutes = %d, want 445", got[0].DurationMinutes)
}
}
func TestListAirportsCoversOnlyThisTenantsNetwork(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
mine.NewFlight(t, testsupport.FlightOpts{Origin: "JFK", Destination: "LHR"})
theirs.NewFlight(t, testsupport.FlightOpts{Origin: "LAX", Destination: "DXB"})
airports, err := mine.Repo.ListAirports(context.Background(), mine.Tenant.ID)
if err != nil {
t.Fatalf("list airports: %v", err)
}
seen := map[string]bool{}
for _, a := range airports {
seen[a.IATACode] = true
}
if !seen["JFK"] || !seen["LHR"] {
t.Errorf("want JFK and LHR in the network, got %v", seen)
}
if seen["LAX"] || seen["DXB"] {
t.Errorf("leaked the other tenant's airports: %v", seen)
}
}
@@ -0,0 +1,72 @@
package repository
import (
"context"
"errors"
"github.com/google/uuid"
"github.com/jackc/pgx/v5"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// TenantBySlug resolves the tenant a caller is signing in to. Suspended
// tenants are treated as absent so their users cannot authenticate.
func (r *Repository) TenantBySlug(ctx context.Context, slug string) (models.Tenant, error) {
const q = `
SELECT id, slug, name, status, created_at
FROM tenants
WHERE slug = $1 AND status = 'active'`
var t models.Tenant
err := r.pool.QueryRow(ctx, q, slug).
Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt)
if errors.Is(err, pgx.ErrNoRows) {
return models.Tenant{}, ErrNotFound
}
return t, err
}
// ListTenants powers the tenant picker on the sign-in screen. It is the only
// unauthenticated read in the service and deliberately exposes nothing beyond
// the slug and display name.
func (r *Repository) ListTenants(ctx context.Context) ([]models.Tenant, error) {
const q = `
SELECT id, slug, name, status, created_at
FROM tenants
WHERE status = 'active'
ORDER BY name`
rows, err := r.pool.Query(ctx, q)
if err != nil {
return nil, err
}
defer rows.Close()
tenants := []models.Tenant{}
for rows.Next() {
var t models.Tenant
if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil {
return nil, err
}
tenants = append(tenants, t)
}
return tenants, rows.Err()
}
// UserByEmail looks up a login within one tenant. The tenant filter is what
// stops credentials from one agency authenticating against another.
func (r *Repository) UserByEmail(ctx context.Context, tenantID uuid.UUID, email string) (models.User, error) {
const q = `
SELECT id, tenant_id, email, password_hash, full_name, role, created_at
FROM users
WHERE tenant_id = $1 AND lower(email) = lower($2)`
var u models.User
err := r.pool.QueryRow(ctx, q, tenantID, email).Scan(
&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt,
)
if errors.Is(err, pgx.ErrNoRows) {
return models.User{}, ErrNotFound
}
return u, err
}
@@ -0,0 +1,117 @@
package repository_test
import (
"context"
"strings"
"testing"
"github.com/travelswitch/travelhub/backend/internal/repository"
"github.com/travelswitch/travelhub/backend/internal/testsupport"
)
func TestTenantBySlug(t *testing.T) {
f := testsupport.Setup(t)
got, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug)
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.ID != f.Tenant.ID {
t.Errorf("got tenant %s, want %s", got.ID, f.Tenant.ID)
}
}
func TestTenantBySlugUnknownIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
if _, err := f.Repo.TenantBySlug(context.Background(), "no-such-agency"); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound, got %v", err)
}
}
// A suspended tenant must be indistinguishable from a missing one, so its
// users cannot sign in.
func TestTenantBySlugSuspendedIsNotFound(t *testing.T) {
f := testsupport.Setup(t)
f.Suspend(t)
if _, err := f.Repo.TenantBySlug(context.Background(), f.Tenant.Slug); err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound for a suspended tenant, got %v", err)
}
}
func TestUserByEmailIsCaseInsensitive(t *testing.T) {
f := testsupport.Setup(t)
got, err := f.Repo.UserByEmail(context.Background(), f.Tenant.ID, strings.ToUpper(f.Agent.Email))
if err != nil {
t.Fatalf("lookup: %v", err)
}
if got.ID != f.Agent.ID {
t.Errorf("got user %s, want %s", got.ID, f.Agent.ID)
}
}
// The tenant filter on the user lookup is what stops one agency's credentials
// from authenticating at another.
func TestUserByEmailIsScopedToTenant(t *testing.T) {
mine := testsupport.Setup(t)
theirs := testsupport.Setup(t)
_, err := mine.Repo.UserByEmail(context.Background(), mine.Tenant.ID, theirs.Agent.Email)
if err != repository.ErrNotFound {
t.Fatalf("want ErrNotFound looking up another tenant's user, got %v", err)
}
}
// The same address may hold an account at more than one agency; each must
// resolve to its own row.
func TestSameEmailCanExistInTwoTenants(t *testing.T) {
a := testsupport.Setup(t)
b := testsupport.Setup(t)
const shared = "traveller@example.com"
inA := a.NewUser(t, shared, "agent")
inB := b.NewUser(t, shared, "admin")
gotA, err := a.Repo.UserByEmail(context.Background(), a.Tenant.ID, shared)
if err != nil {
t.Fatalf("lookup in A: %v", err)
}
gotB, err := b.Repo.UserByEmail(context.Background(), b.Tenant.ID, shared)
if err != nil {
t.Fatalf("lookup in B: %v", err)
}
if gotA.ID != inA.ID || gotB.ID != inB.ID {
t.Error("shared email resolved to the wrong tenant's user")
}
if gotA.ID == gotB.ID {
t.Error("the two tenants collapsed onto one user row")
}
if gotA.Role != "agent" || gotB.Role != "admin" {
t.Errorf("roles crossed over: A=%s B=%s", gotA.Role, gotB.Role)
}
}
func TestListTenantsExcludesSuspended(t *testing.T) {
live := testsupport.Setup(t)
dead := testsupport.Setup(t)
dead.Suspend(t)
tenants, err := live.Repo.ListTenants(context.Background())
if err != nil {
t.Fatalf("list: %v", err)
}
seen := map[string]bool{}
for _, tn := range tenants {
seen[tn.Slug] = true
}
if !seen[live.Tenant.Slug] {
t.Error("active tenant missing from the sign-in picker")
}
if seen[dead.Tenant.Slug] {
t.Error("suspended tenant offered in the sign-in picker")
}
}
@@ -0,0 +1,135 @@
package repository
import (
"context"
"github.com/google/uuid"
"github.com/travelswitch/travelhub/backend/internal/models"
)
// ListAirports returns the airports this tenant actually flies to or from,
// which is what the search form's dropdowns should offer. Airports themselves
// are global, but the filter keeps each agency's UI limited to its network.
func (r *Repository) ListAirports(ctx context.Context, tenantID uuid.UUID) ([]models.Airport, error) {
const q = `
SELECT DISTINCT a.id, a.iata_code, a.name, a.city, a.country, a.timezone
FROM airports a
WHERE EXISTS (
SELECT 1 FROM flights f
WHERE f.tenant_id = $1
AND (f.origin_airport_id = a.id OR f.destination_airport_id = a.id)
)
ORDER BY a.city, a.iata_code`
rows, err := r.pool.Query(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
airports := []models.Airport{}
for rows.Next() {
var a models.Airport
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.City, &a.Country, &a.Timezone); err != nil {
return nil, err
}
airports = append(airports, a)
}
return airports, rows.Err()
}
// ListAirlines returns the carriers present in this tenant's inventory.
func (r *Repository) ListAirlines(ctx context.Context, tenantID uuid.UUID) ([]models.Airline, error) {
const q = `
SELECT DISTINCT al.id, al.iata_code, al.name, COALESCE(al.logo_url, '')
FROM airlines al
JOIN flights f ON f.airline_id = al.id AND f.tenant_id = $1
ORDER BY al.name`
rows, err := r.pool.Query(ctx, q, tenantID)
if err != nil {
return nil, err
}
defer rows.Close()
airlines := []models.Airline{}
for rows.Next() {
var a models.Airline
if err := rows.Scan(&a.ID, &a.IATACode, &a.Name, &a.LogoURL); err != nil {
return nil, err
}
airlines = append(airlines, a)
}
return airlines, rows.Err()
}
// DashboardStats aggregates one tenant's trading position.
func (r *Repository) DashboardStats(ctx context.Context, tenantID uuid.UUID) (models.DashboardStats, error) {
var s models.DashboardStats
s.Currency = "USD"
const totalsSQL = `
SELECT COUNT(*),
COUNT(*) FILTER (WHERE status = 'confirmed'),
COUNT(*) FILTER (WHERE status = 'cancelled'),
COALESCE(SUM(total_amount) FILTER (WHERE status = 'confirmed'), 0),
COALESCE(SUM(passenger_count) FILTER (WHERE status = 'confirmed'), 0)
FROM bookings
WHERE tenant_id = $1`
if err := r.pool.QueryRow(ctx, totalsSQL, tenantID).Scan(
&s.TotalBookings, &s.ConfirmedBookings, &s.CancelledBookings,
&s.TotalRevenue, &s.SeatsSold,
); err != nil {
return s, err
}
const stockSQL = `
SELECT COUNT(*) FROM flights
WHERE tenant_id = $1 AND departure_time > now() AND status <> 'cancelled'`
if err := r.pool.QueryRow(ctx, stockSQL, tenantID).Scan(&s.FlightsInStock); err != nil {
return s, err
}
const routesSQL = `
SELECT o.iata_code || ' to ' || d.iata_code AS route,
COUNT(*) AS bookings,
COALESCE(SUM(b.total_amount), 0) AS revenue
FROM bookings b
JOIN flights f ON f.id = b.flight_id
JOIN airports o ON o.id = f.origin_airport_id
JOIN airports d ON d.id = f.destination_airport_id
WHERE b.tenant_id = $1 AND b.status = 'confirmed'
GROUP BY route
ORDER BY revenue DESC
LIMIT 5`
rows, err := r.pool.Query(ctx, routesSQL, tenantID)
if err != nil {
return s, err
}
defer rows.Close()
for rows.Next() {
var entry struct {
Route string `json:"route"`
Bookings int `json:"bookings"`
Revenue float64 `json:"revenue"`
}
if err := rows.Scan(&entry.Route, &entry.Bookings, &entry.Revenue); err != nil {
return s, err
}
s.TopRoutes = append(s.TopRoutes, entry)
}
if err := rows.Err(); err != nil {
return s, err
}
recent, _, err := r.ListBookings(ctx, tenantID, "", 5, 0)
if err != nil {
return s, err
}
s.RecentBookings = recent
return s, nil
}
+39
View File
@@ -0,0 +1,39 @@
// Package repository holds every SQL statement in the service.
//
// Tenant isolation convention: any query touching a tenant-owned table
// (users, flights, bookings, passengers) takes tenantID as its FIRST argument
// and filters on it in the WHERE clause. Callers pass the id from the
// authenticated identity, never from client input.
package repository
import (
"errors"
"github.com/jackc/pgx/v5/pgconn"
"github.com/jackc/pgx/v5/pgxpool"
)
// ErrNotFound is returned when a lookup matches no row within the tenant.
// Handlers map it to 404, which also means a caller probing for another
// tenant's record sees a plain "not found" rather than a permission error.
var ErrNotFound = errors.New("not found")
// ErrNoSeats is returned when a booking asks for more seats than remain.
var ErrNoSeats = errors.New("not enough seats available")
// Repository is the data access layer over a pgx pool.
type Repository struct {
pool *pgxpool.Pool
}
// New builds a Repository around an open pool.
func New(pool *pgxpool.Pool) *Repository {
return &Repository{pool: pool}
}
// isUniqueViolation reports whether err is PostgreSQL's 23505, which lets
// callers retry a generated value instead of failing the request.
func isUniqueViolation(err error) bool {
var pgErr *pgconn.PgError
return errors.As(err, &pgErr) && pgErr.Code == "23505"
}
+329
View File
@@ -0,0 +1,329 @@
// Package testsupport wires tests to a real PostgreSQL database.
//
// Isolation strategy: every test gets its own freshly created tenant. Because
// the whole application is tenant-scoped by design, a per-test tenant gives
// each test a private world without truncating tables between runs -- and it
// exercises the tenant scoping on every single query rather than trusting it.
//
// Set TEST_DATABASE_URL to point at a throwaway database. The default assumes
// a local "travelhub_test". The schema is applied automatically on first use.
package testsupport
import (
"context"
"fmt"
"net/url"
"os"
"path/filepath"
"runtime"
"sync"
"testing"
"time"
"github.com/google/uuid"
"github.com/jackc/pgx/v5/pgxpool"
"golang.org/x/crypto/bcrypt"
"github.com/travelswitch/travelhub/backend/internal/config"
"github.com/travelswitch/travelhub/backend/internal/models"
"github.com/travelswitch/travelhub/backend/internal/repository"
)
// Password is the plaintext behind every user created by this package.
const Password = "testpass123"
var (
poolOnce sync.Once
pool *pgxpool.Pool
poolErr error
)
// Fixture is one test's private slice of the world.
type Fixture struct {
Pool *pgxpool.Pool
Repo *repository.Repository
Tenant models.Tenant
Admin models.User
Agent models.User
}
// Setup returns a Fixture backed by a brand-new tenant. The tenant and
// everything that cascades from it are removed when the test finishes.
func Setup(t *testing.T) *Fixture {
t.Helper()
p := sharedPool(t)
ctx := context.Background()
slug := "test-" + uuid.NewString()[:8]
var tenant models.Tenant
err := p.QueryRow(ctx,
`INSERT INTO tenants (slug, name) VALUES ($1, $2)
RETURNING id, slug, name, status, created_at`,
slug, "Test Agency "+slug,
).Scan(&tenant.ID, &tenant.Slug, &tenant.Name, &tenant.Status, &tenant.CreatedAt)
if err != nil {
t.Fatalf("create tenant: %v", err)
}
t.Cleanup(func() {
// ON DELETE CASCADE clears users, flights, bookings and passengers.
if _, err := p.Exec(context.Background(),
`DELETE FROM tenants WHERE id = $1`, tenant.ID); err != nil {
t.Logf("cleanup tenant %s: %v", tenant.Slug, err)
}
})
f := &Fixture{Pool: p, Repo: repository.New(p), Tenant: tenant}
f.Admin = f.NewUser(t, "admin@"+slug+".test", "admin")
f.Agent = f.NewUser(t, "agent@"+slug+".test", "agent")
return f
}
// NewUser adds a user to the fixture's tenant.
func (f *Fixture) NewUser(t *testing.T, email, role string) models.User {
t.Helper()
// MinCost keeps the suite fast; the middleware verifies any cost.
hash, err := bcrypt.GenerateFromPassword([]byte(Password), bcrypt.MinCost)
if err != nil {
t.Fatalf("hash password: %v", err)
}
var u models.User
err = f.Pool.QueryRow(context.Background(),
`INSERT INTO users (tenant_id, email, password_hash, full_name, role)
VALUES ($1, $2, $3, $4, $5)
RETURNING id, tenant_id, email, password_hash, full_name, role, created_at`,
f.Tenant.ID, email, string(hash), "Test "+role, role,
).Scan(&u.ID, &u.TenantID, &u.Email, &u.PasswordHash, &u.FullName, &u.Role, &u.CreatedAt)
if err != nil {
t.Fatalf("create user %s: %v", email, err)
}
return u
}
// FlightOpts describes a flight to insert. Zero values get sensible defaults.
type FlightOpts struct {
Number string
Origin string // IATA, default JFK
Destination string // IATA, default LHR
DepartsIn time.Duration
Duration time.Duration
Cabin string
Price float64
Seats int
Available *int // defaults to Seats
Status string
}
// NewFlight inserts one flight into the fixture's tenant and returns it.
func (f *Fixture) NewFlight(t *testing.T, opts FlightOpts) models.Flight {
t.Helper()
ctx := context.Background()
if opts.Number == "" {
opts.Number = "TT" + uuid.NewString()[:4]
}
if opts.Origin == "" {
opts.Origin = "JFK"
}
if opts.Destination == "" {
opts.Destination = "LHR"
}
if opts.DepartsIn == 0 {
opts.DepartsIn = 48 * time.Hour
}
if opts.Duration == 0 {
opts.Duration = 7 * time.Hour
}
if opts.Cabin == "" {
opts.Cabin = "economy"
}
if opts.Price == 0 {
opts.Price = 500
}
if opts.Seats == 0 {
opts.Seats = 100
}
if opts.Status == "" {
opts.Status = "scheduled"
}
available := opts.Seats
if opts.Available != nil {
available = *opts.Available
}
departs := time.Now().Add(opts.DepartsIn)
var id uuid.UUID
err := f.Pool.QueryRow(ctx,
`INSERT INTO flights (tenant_id, airline_id, flight_number,
origin_airport_id, destination_airport_id,
departure_time, arrival_time, aircraft, cabin_class,
base_price, currency, seats_total, seats_available, status)
VALUES ($1,
(SELECT id FROM airlines WHERE iata_code = 'AA'),
$2,
(SELECT id FROM airports WHERE iata_code = $3),
(SELECT id FROM airports WHERE iata_code = $4),
$5, $6, 'Boeing 787-9', $7, $8, 'USD', $9, $10, $11)
RETURNING id`,
f.Tenant.ID, opts.Number, opts.Origin, opts.Destination,
departs, departs.Add(opts.Duration), opts.Cabin, opts.Price,
opts.Seats, available, opts.Status,
).Scan(&id)
if err != nil {
t.Fatalf("create flight: %v", err)
}
flight, err := f.Repo.FlightByID(ctx, f.Tenant.ID, id)
if err != nil {
t.Fatalf("read back flight: %v", err)
}
return flight
}
// SeatsAvailable reads the current availability straight from the row, for
// assertions about seat accounting.
func (f *Fixture) SeatsAvailable(t *testing.T, flightID uuid.UUID) int {
t.Helper()
var seats int
err := f.Pool.QueryRow(context.Background(),
`SELECT seats_available FROM flights WHERE id = $1`, flightID).Scan(&seats)
if err != nil {
t.Fatalf("read seats: %v", err)
}
return seats
}
// Suspend flips the tenant to suspended, for tests that check sign-in is
// refused afterwards.
func (f *Fixture) Suspend(t *testing.T) {
t.Helper()
if _, err := f.Pool.Exec(context.Background(),
`UPDATE tenants SET status = 'suspended' WHERE id = $1`, f.Tenant.ID); err != nil {
t.Fatalf("suspend tenant: %v", err)
}
}
// sharedPool opens the pool once per test binary and prepares the schema.
func sharedPool(t *testing.T) *pgxpool.Pool {
t.Helper()
poolOnce.Do(func() {
// Pick up backend/.env so a developer who configured the app can run
// the tests without setting anything extra.
config.LoadDotEnv()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
p, err := pgxpool.New(ctx, databaseURL())
if err != nil {
poolErr = fmt.Errorf("open test pool: %w", err)
return
}
if err := p.Ping(ctx); err != nil {
poolErr = fmt.Errorf("ping test database: %w", err)
return
}
if err := applySchema(ctx, p); err != nil {
poolErr = err
return
}
if err := seedReference(ctx, p); err != nil {
poolErr = err
return
}
pool = p
})
if poolErr != nil {
t.Fatalf("test database unavailable: %v\n"+
"Set TEST_DATABASE_URL, or create the default database with:\n"+
" createdb -U postgres travelhub_test", poolErr)
}
return pool
}
// databaseURL resolves the test DSN, preferring TEST_DATABASE_URL and
// otherwise assembling one from the same DB_* variables the app uses, with the
// database name swapped for the test one.
func databaseURL() string {
if raw := os.Getenv("TEST_DATABASE_URL"); raw != "" {
return raw
}
user := envOr("DB_USER", "postgres")
pass := envOr("DB_PASSWORD", "postgres")
host := envOr("DB_HOST", "localhost")
port := envOr("DB_PORT", "5432")
name := envOr("TEST_DB_NAME", "travelhub_test")
u := &url.URL{
Scheme: "postgres",
User: url.UserPassword(user, pass),
Host: host + ":" + port,
Path: "/" + name,
RawQuery: "sslmode=disable",
}
return u.String()
}
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
// applySchema runs 001_init.sql, which is written to be re-runnable.
func applySchema(ctx context.Context, p *pgxpool.Pool) error {
path := filepath.Join(moduleRoot(), "migrations", "001_init.sql")
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read schema %s: %w", path, err)
}
// Exec with no arguments uses the simple protocol, which accepts a script
// of several statements.
if _, err := p.Exec(ctx, string(sql)); err != nil {
return fmt.Errorf("apply schema: %w", err)
}
return nil
}
// seedReference inserts the global airports and airlines the fixtures need.
func seedReference(ctx context.Context, p *pgxpool.Pool) error {
const sql = `
INSERT INTO airports (iata_code, name, city, country, timezone) VALUES
('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'),
('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'),
('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'),
('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai')
ON CONFLICT (iata_code) DO NOTHING;
INSERT INTO airlines (iata_code, name) VALUES
('AA', 'American Airlines'),
('BA', 'British Airways')
ON CONFLICT (iata_code) DO NOTHING;`
if _, err := p.Exec(ctx, sql); err != nil {
return fmt.Errorf("seed reference data: %w", err)
}
return nil
}
// moduleRoot locates backend/ from this file's compile-time path, so tests can
// find migrations no matter which package directory they run from.
func moduleRoot() string {
_, thisFile, _, ok := runtime.Caller(0)
if !ok {
return "."
}
// .../backend/internal/testsupport/testsupport.go -> .../backend
return filepath.Dir(filepath.Dir(filepath.Dir(thisFile)))
}
+107
View File
@@ -0,0 +1,107 @@
-- TravelHub: multi-tenant flight booking schema
-- Strategy: shared database, shared schema, tenant_id discriminator on every
-- tenant-owned table. Reference data (airports, airlines) is global.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ---------------------------------------------------------------- tenants --
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ------------------------------------------------------------------ users --
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'agent'
CHECK (role IN ('admin', 'agent')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The same email may exist in different tenants; unique only within one.
CONSTRAINT users_tenant_email_key UNIQUE (tenant_id, email)
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
-- ------------------------------------------------- reference data (global) --
CREATE TABLE IF NOT EXISTS airports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iata_code CHAR(3) NOT NULL UNIQUE,
name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC'
);
CREATE TABLE IF NOT EXISTS airlines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iata_code CHAR(2) NOT NULL UNIQUE,
name TEXT NOT NULL,
logo_url TEXT
);
-- ---------------------------------------------------------------- flights --
-- Inventory is per-tenant: each agency loads its own negotiated fares.
CREATE TABLE IF NOT EXISTS flights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
airline_id UUID NOT NULL REFERENCES airlines(id),
flight_number TEXT NOT NULL,
origin_airport_id UUID NOT NULL REFERENCES airports(id),
destination_airport_id UUID NOT NULL REFERENCES airports(id),
departure_time TIMESTAMPTZ NOT NULL,
arrival_time TIMESTAMPTZ NOT NULL,
aircraft TEXT NOT NULL DEFAULT '',
cabin_class TEXT NOT NULL DEFAULT 'economy'
CHECK (cabin_class IN ('economy', 'premium_economy', 'business', 'first')),
base_price NUMERIC(10,2) NOT NULL CHECK (base_price >= 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
seats_total INTEGER NOT NULL CHECK (seats_total > 0),
seats_available INTEGER NOT NULL CHECK (seats_available >= 0),
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'delayed', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT flights_seats_check CHECK (seats_available <= seats_total),
CONSTRAINT flights_times_check CHECK (arrival_time > departure_time)
);
CREATE INDEX IF NOT EXISTS idx_flights_search
ON flights(tenant_id, origin_airport_id, destination_airport_id, departure_time);
-- --------------------------------------------------------------- bookings --
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
flight_id UUID NOT NULL REFERENCES flights(id),
booking_reference TEXT NOT NULL,
passenger_count INTEGER NOT NULL CHECK (passenger_count > 0),
total_amount NUMERIC(10,2) NOT NULL CHECK (total_amount >= 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'cancelled')),
contact_email TEXT NOT NULL,
contact_phone TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- PNR is unique per tenant, not globally.
CONSTRAINT bookings_tenant_ref_key UNIQUE (tenant_id, booking_reference)
);
CREATE INDEX IF NOT EXISTS idx_bookings_tenant_created ON bookings(tenant_id, created_at DESC);
-- ------------------------------------------------------------- passengers --
CREATE TABLE IF NOT EXISTS passengers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
date_of_birth DATE,
passport_number TEXT NOT NULL DEFAULT '',
seat_number TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_passengers_booking ON passengers(booking_id);
+121
View File
@@ -0,0 +1,121 @@
-- Demo data for TravelHub. Safe to re-run: every insert is idempotent.
-- Flight departures are generated relative to now(), so seeded inventory is
-- always in the future no matter when you run this.
-- ---------------------------------------------------------------- tenants --
INSERT INTO tenants (slug, name) VALUES
('skyline', 'Skyline Travel'),
('globetrotter', 'Globetrotter Tours')
ON CONFLICT (slug) DO NOTHING;
-- ------------------------------------------------------------------ users --
-- Passwords are bcrypt hashes produced by pgcrypto's crypt()/gen_salt('bf'),
-- which golang.org/x/crypto/bcrypt verifies directly. Demo password for every
-- seeded account is: demo1234
INSERT INTO users (tenant_id, email, password_hash, full_name, role)
SELECT t.id, v.email, crypt('demo1234', gen_salt('bf')), v.full_name, v.role
FROM tenants t
JOIN (VALUES
('skyline', 'admin@skyline.test', 'Ava Chen', 'admin'),
('skyline', 'agent@skyline.test', 'Marcus Reid', 'agent'),
('globetrotter', 'admin@globetrotter.test', 'Priya Nair', 'admin'),
('globetrotter', 'agent@globetrotter.test', 'Tom Okafor', 'agent')
) AS v(slug, email, full_name, role) ON v.slug = t.slug
ON CONFLICT (tenant_id, email) DO NOTHING;
-- --------------------------------------------------------------- airports --
INSERT INTO airports (iata_code, name, city, country, timezone) VALUES
('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'),
('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'),
('SFO', 'San Francisco International', 'San Francisco', 'United States', 'America/Los_Angeles'),
('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'),
('CDG', 'Charles de Gaulle', 'Paris', 'France', 'Europe/Paris'),
('FRA', 'Frankfurt am Main', 'Frankfurt', 'Germany', 'Europe/Berlin'),
('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai'),
('SIN', 'Changi', 'Singapore', 'Singapore', 'Asia/Singapore'),
('HND', 'Haneda', 'Tokyo', 'Japan', 'Asia/Tokyo'),
('SYD', 'Kingsford Smith', 'Sydney', 'Australia', 'Australia/Sydney'),
('BOM', 'Chhatrapati Shivaji Maharaj', 'Mumbai', 'India', 'Asia/Kolkata'),
('DEL', 'Indira Gandhi International', 'Delhi', 'India', 'Asia/Kolkata')
ON CONFLICT (iata_code) DO NOTHING;
-- --------------------------------------------------------------- airlines --
INSERT INTO airlines (iata_code, name) VALUES
('AA', 'American Airlines'),
('BA', 'British Airways'),
('EK', 'Emirates'),
('SQ', 'Singapore Airlines'),
('LH', 'Lufthansa'),
('AI', 'Air India'),
('UA', 'United Airlines'),
('AF', 'Air France')
ON CONFLICT (iata_code) DO NOTHING;
-- ---------------------------------------------------------------- flights --
-- Each tenant carries its own inventory of the same routes at its own fares:
-- Skyline sells at list price, Globetrotter at a 12% negotiated markup. This
-- is what makes tenant isolation visible in the UI -- same route, different
-- price and different availability depending on who is logged in.
WITH routes(airline, flight_no, origin, dest, dep_hour, dur_hours, cabin, price, seats) AS (
VALUES
('AA', 'AA100', 'JFK', 'LHR', 18, 7, 'economy', 540.00, 180),
('BA', 'BA178', 'JFK', 'LHR', 21, 7, 'business', 2450.00, 40),
('AA', 'AA136', 'LAX', 'JFK', 8, 5, 'economy', 310.00, 200),
('UA', 'UA523', 'SFO', 'JFK', 7, 5, 'economy', 295.00, 190),
('EK', 'EK202', 'JFK', 'DXB', 23, 12, 'economy', 780.00, 350),
('EK', 'EK506', 'DXB', 'BOM', 3, 3, 'economy', 210.00, 300),
('SQ', 'SQ321', 'SIN', 'LHR', 10, 13, 'business', 3100.00, 50),
('SQ', 'SQ001', 'SIN', 'SFO', 9, 15, 'economy', 890.00, 280),
('LH', 'LH400', 'FRA', 'JFK', 13, 8, 'economy', 495.00, 220),
('AF', 'AF006', 'CDG', 'JFK', 11, 8, 'premium_economy', 1150.00, 60),
('AI', 'AI101', 'DEL', 'JFK', 2, 15, 'economy', 720.00, 240),
('AI', 'AI864', 'BOM', 'DEL', 6, 2, 'economy', 85.00, 160),
('BA', 'BA015', 'LHR', 'SYD', 22, 22, 'economy', 1080.00, 260),
('LH', 'LH716', 'FRA', 'HND', 14, 11, 'economy', 640.00, 230)
),
priced AS (
SELECT t.id AS tenant_id,
al.id AS airline_id,
r.flight_no,
o.id AS origin_id,
d.id AS dest_id,
r.cabin,
r.seats,
day.n AS day_offset,
r.dur_hours,
-- Fares drift with departure date so search results look realistic.
ROUND(
(r.price * CASE t.slug WHEN 'globetrotter' THEN 1.12 ELSE 1.00 END)
* (1 + ((day.n % 7) - 3) * 0.035)
, 2) AS price,
date_trunc('day', now()) + (day.n || ' days')::interval
+ (r.dep_hour || ' hours')::interval AS departs
FROM routes r
CROSS JOIN generate_series(1, 21) AS day(n)
CROSS JOIN tenants t
JOIN airlines al ON al.iata_code = r.airline
JOIN airports o ON o.iata_code = r.origin
JOIN airports d ON d.iata_code = r.dest
)
INSERT INTO flights (
tenant_id, airline_id, flight_number, origin_airport_id, destination_airport_id,
departure_time, arrival_time, aircraft, cabin_class,
base_price, currency, seats_total, seats_available
)
SELECT p.tenant_id, p.airline_id, p.flight_no, p.origin_id, p.dest_id,
p.departs,
p.departs + (p.dur_hours || ' hours')::interval,
'Boeing 787-9',
p.cabin,
p.price,
'USD',
p.seats,
-- Nearer departures are fuller, so the UI shows varied availability.
GREATEST(1, p.seats - ((22 - p.day_offset) * 7) % (p.seats / 2))
FROM priced p
WHERE NOT EXISTS (
SELECT 1 FROM flights f
WHERE f.tenant_id = p.tenant_id
AND f.flight_number = p.flight_no
AND f.departure_time = p.departs
);
+122
View File
@@ -0,0 +1,122 @@
-- Demo reservations, so the dashboard shows a trading picture on first run
-- instead of an empty state. Both agencies have sold tickets, at their own
-- fares, on their own inventory.
--
-- The whole file is one guarded block: if the demo references are already
-- present it returns immediately, which keeps seat counts from being
-- decremented twice on a re-run.
DO $$
DECLARE
already_seeded boolean;
BEGIN
SELECT EXISTS (SELECT 1 FROM bookings WHERE booking_reference = 'QX7A2M')
INTO already_seeded;
IF already_seeded THEN
RAISE NOTICE 'demo bookings already present, skipping';
RETURN;
END IF;
-- Rank each tenant's future inventory so the same flights are chosen on
-- every machine, then attach a booking to a handful of them.
CREATE TEMP TABLE demo_picks ON COMMIT DROP AS
WITH ranked AS (
SELECT f.id AS flight_id,
f.tenant_id,
f.base_price,
f.currency,
t.slug,
row_number() OVER (PARTITION BY t.slug
ORDER BY f.departure_time, f.flight_number) AS rn
FROM flights f
JOIN tenants t ON t.id = f.tenant_id
WHERE f.departure_time > now()
),
spec(slug, rn, pnr, pax, email, phone, status) AS (
VALUES
('skyline', 2, 'QX7A2M', 2, 'harper.quinn@example.com', '+1-212-555-0143', 'confirmed'),
('skyline', 7, 'MT4RB9', 1, 'd.oyelaran@example.com', '+1-415-555-0199', 'confirmed'),
('skyline', 15, 'KP2VJ8', 4, 'the.sandovals@example.com','+1-646-555-0117', 'confirmed'),
('skyline', 23, 'RN9WD3', 2, 'a.lindqvist@example.com', '+44-20-7946-0821','confirmed'),
('skyline', 31, 'HB6ZC5', 3, 'marco.bellini@example.com','+39-06-555-0164', 'confirmed'),
('skyline', 44, 'VF3LK7', 1, 'j.nakamura@example.com', '+81-3-5555-0132', 'cancelled'),
('globetrotter', 4, 'TG8MH2', 2, 'priya.raman@example.com', '+91-22-5555-0178','confirmed'),
('globetrotter', 11, 'WS5QN6', 3, 'okoro.family@example.com', '+234-1-555-0155', 'confirmed'),
('globetrotter', 19, 'ZD7XP4', 1, 'l.fontaine@example.com', '+33-1-5555-0190', 'confirmed'),
('globetrotter', 27, 'CJ2YR8', 2, 'sam.whitaker@example.com', '+61-2-5555-0146', 'confirmed')
),
-- Attribute every demo sale to that tenant's agent account.
selling_agent AS (
SELECT DISTINCT ON (tenant_id) tenant_id, id
FROM users
WHERE role = 'agent'
ORDER BY tenant_id, email
)
SELECT r.flight_id,
r.tenant_id,
a.id AS user_id,
s.pnr,
s.pax,
s.email,
s.phone,
s.status,
ROUND(r.base_price * s.pax, 2) AS total_amount,
r.currency
FROM spec s
JOIN ranked r ON r.slug = s.slug AND r.rn = s.rn
JOIN selling_agent a ON a.tenant_id = r.tenant_id;
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
passenger_count, total_amount, currency, status,
contact_email, contact_phone, created_at)
SELECT p.tenant_id, p.user_id, p.flight_id, p.pnr, p.pax, p.total_amount,
p.currency, p.status, p.email, p.phone,
-- Spread creation times over the past three weeks.
now() - ((row_number() OVER (ORDER BY p.pnr) * 47) || ' hours')::interval
FROM demo_picks p;
-- Give each booking its own travellers from a small pool of names.
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
date_of_birth, passport_number)
SELECT b.tenant_id,
b.id,
n.first_name,
n.last_name,
DATE '1970-01-01' + ((n.idx * 1447) || ' days')::interval,
n.passport
FROM (
SELECT bk.id, bk.tenant_id, bk.passenger_count,
row_number() OVER (ORDER BY bk.booking_reference) AS bn
FROM bookings bk
JOIN demo_picks dp ON dp.pnr = bk.booking_reference
AND dp.tenant_id = bk.tenant_id
) b
CROSS JOIN LATERAL generate_series(1, b.passenger_count) AS g(i)
JOIN (VALUES
(0, 'Harper', 'Quinn', 'P1029384'),
(1, 'Milo', 'Vance', 'P5568211'),
(2, 'Ada', 'Okonjo', 'P7741903'),
(3, 'Ben', 'Sorensen', 'P3310927'),
(4, 'Ines', 'Marchetti', 'P9084417'),
(5, 'Theo', 'Nakamura', 'P2276650'),
(6, 'Rania', 'Haddad', 'P6693108'),
(7, 'Oscar', 'Lindqvist', 'P4417789'),
(8, 'Nadia', 'Petrov', 'P8820134'),
(9, 'Caleb', 'Mwangi', 'P3345562'),
(10, 'Sofia', 'Delgado', 'P7719026'),
(11, 'Arjun', 'Raman', 'P5502943')
) AS n(idx, first_name, last_name, passport)
ON n.idx = (b.bn * 5 + g.i) % 12;
-- Confirmed sales consume seats; cancelled ones do not.
UPDATE flights f
SET seats_available = GREATEST(0, f.seats_available - sold.pax)
FROM (SELECT flight_id, SUM(pax) AS pax
FROM demo_picks
WHERE status = 'confirmed'
GROUP BY flight_id) sold
WHERE f.id = sold.flight_id;
RAISE NOTICE 'demo bookings created';
END $$;