Initial commit: TravelHub multi-tenant flight booking platform
Angular + Go + PostgreSQL sample with strict per-tenant isolation. - Backend: Go API (basic auth, tenant-scoped repository layer, pgx pool) - Frontend: Angular 22 SPA (search, checkout, bookings, dashboard) - Database: schema, seed data and demo bookings migrations - Tests: 311 across Go (integration), Angular (unit) and Playwright (e2e), plus a generated evidence report with PDF export - Security: whitebox VAPT report under security/ Secrets (.env, tokens, DB credentials) are git-ignored. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"})
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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, ¤cy, &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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)))
|
||||
}
|
||||
@@ -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);
|
||||
@@ -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
|
||||
);
|
||||
@@ -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 $$;
|
||||
Reference in New Issue
Block a user