Initial commit: TravelHub multi-tenant flight booking platform

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

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

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

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nilesh.p
2026-09-13 11:38:01 +05:30
co-authored by Claude Opus 4.8
commit 4d3eb18a6c
129 changed files with 25424 additions and 0 deletions
+231
View File
@@ -0,0 +1,231 @@
# TravelHub
A multi-tenant flight booking application: **Angular** frontend, **Go** backend,
**PostgreSQL** database. Built as a reference sample for travel-domain SaaS,
where several travel agencies ("tenants") share one deployment but never see
each other's inventory, customers, or revenue.
Authentication is HTTP **Basic auth**, as requested, scoped per tenant.
---
## Prerequisites
| Tool | Version used here |
| ---------- | ----------------- |
| Go | 1.27 |
| Node.js | 24 LTS |
| PostgreSQL | 17 |
## Quick start
**1. Configure the database connection**
```bash
cd backend
cp .env.example .env # then edit .env with your PostgreSQL credentials
```
**2. Create the database and load the schema**
```bash
createdb -U postgres travelhub
psql -U postgres -d travelhub -f migrations/001_init.sql
psql -U postgres -d travelhub -f migrations/002_seed.sql
psql -U postgres -d travelhub -f migrations/003_demo_bookings.sql
```
All three are safe to re-run: `002` skips rows that already exist, and `003`
returns early once its bookings are present, so seats are never
double-decremented.
On Windows, `createdb` and `psql` live in `C:\Program Files\PostgreSQL\17\bin`.
**3. Run the API** (listens on `http://localhost:8080`)
```bash
cd backend
go run ./cmd/api
```
**4. Run the frontend** (serves `http://localhost:4200`)
```bash
cd frontend
npm install
npm start
```
Open <http://localhost:4200> and sign in with any demo account below.
## Demo accounts
Every seeded account uses the password **`demo1234`**.
| Tenant | Email | Role |
| ------------------ | ------------------------- | ----- |
| Skyline Travel | `admin@skyline.test` | admin |
| Skyline Travel | `agent@skyline.test` | agent |
| Globetrotter Tours | `admin@globetrotter.test` | admin |
| Globetrotter Tours | `agent@globetrotter.test` | agent |
Sign in to each tenant in turn and search `JFK → LHR`: both agencies sell the
same routes, but Globetrotter's fares carry a 12% negotiated markup and
availability differs. That contrast is the point of the sample — it makes
tenant isolation visible rather than theoretical.
---
## How multi-tenancy works
**Storage model:** shared database, shared schema, `tenant_id` discriminator.
Every tenant-owned table (`users`, `flights`, `bookings`, `passengers`) carries
a `tenant_id` foreign key. Reference data that is genuinely global — `airports`
and `airlines` — does not.
This model was chosen over schema-per-tenant or database-per-tenant because it
keeps migrations single-shot and connection pooling simple, which is the right
trade for a sample and for most early-stage travel SaaS. The cost is that
isolation is enforced in application code rather than by the database, so the
codebase holds that line in three specific places:
1. **The tenant is resolved from credentials, not from client input.** The
`X-Tenant` header (or subdomain) names an agency, but the password is then
verified against the user row *inside that tenant*. Credentials valid at one
agency return 401 at another.
2. **Handlers never read a tenant id from the request.** They take it from the
authenticated identity in the request context. See the package comment in
[`internal/handlers`](backend/internal/handlers/handlers.go).
3. **Every tenant-scoped query filters on `tenant_id` as `$1`.** This is a
stated convention in [`internal/repository`](backend/internal/repository/repository.go),
and a lookup that misses returns `ErrNotFound` → HTTP 404, so probing for
another tenant's record is indistinguishable from asking for one that does
not exist.
**If you take this to production,** add PostgreSQL row-level security as a
second line of defence, so a missed `WHERE` clause fails closed at the database
rather than leaking rows.
## Authentication
Basic auth, per the brief. Credentials travel in the standard
`Authorization: Basic base64(email:password)` header alongside `X-Tenant`.
Passwords are stored as bcrypt hashes (seeded via pgcrypto's
`crypt()/gen_salt('bf')`, which `golang.org/x/crypto/bcrypt` verifies directly).
Two deliberate details:
- **401 responses omit `WWW-Authenticate`.** Sending it would make the browser
open its own credential dialog, which would fight with the Angular sign-in
screen.
- **A missing user still pays for a bcrypt comparison**, so response timing
does not reveal which email addresses are registered.
> **Production note:** Basic auth sends re-usable credentials on every request.
> It is fine behind TLS for an internal tool or a sample, but a public
> deployment wants short-lived tokens (JWT or session cookies) and a refresh
> flow. The auth logic is isolated in
> [`internal/middleware`](backend/internal/middleware/middleware.go) and
> [`internal/auth`](backend/internal/auth/auth.go), so swapping it touches two
> files and no handler.
## API
All endpoints require `Authorization` and `X-Tenant` headers except the two
marked public.
| Method | Path | Purpose |
| ------ | --------------------------- | ------------------------------------------- |
| GET | `/api/health` | Liveness check (**public**) |
| GET | `/api/tenants` | Agency list for the sign-in picker (**public**) |
| GET | `/api/session` | Current user and tenant; validates sign-in |
| GET | `/api/flights` | Search inventory |
| GET | `/api/flights/{id}` | One flight |
| GET | `/api/airports` | Airports in this tenant's network |
| GET | `/api/airlines` | Carriers in this tenant's inventory |
| GET | `/api/bookings` | List bookings (`?status=confirmed`) |
| POST | `/api/bookings` | Create a booking |
| GET | `/api/bookings/{id}` | One booking with passengers |
| POST | `/api/bookings/{id}/cancel` | Cancel and return seats to inventory |
| GET | `/api/dashboard` | Agency totals, top routes, recent bookings |
**Flight search parameters:** `origin`, `destination` (IATA codes), `date`
(`YYYY-MM-DD`), `cabin` (`economy`, `premium_economy`, `business`, `first`),
`passengers`, `sort` (`price`, `duration`, `departure`), `limit`, `offset`.
Example:
```bash
curl -u agent@skyline.test:demo1234 -H 'X-Tenant: skyline' \
'http://localhost:8080/api/flights?origin=JFK&destination=LHR&sort=price&limit=5'
```
### Overselling
`POST /api/bookings` 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 concurrently cannot oversell — the second waits and then sees the
decremented count. Cancelling restores the seats and is guarded against
double-crediting by filtering on `status = 'confirmed'` in the `UPDATE`.
## Project layout
```
TS_Proto/
├── backend/
│ ├── cmd/api/main.go server entrypoint, routes, shutdown
│ ├── internal/
│ │ ├── auth/ identity in request context
│ │ ├── config/ .env loading, DSN assembly
│ │ ├── database/ pgx pool
│ │ ├── handlers/ HTTP layer
│ │ ├── middleware/ basic auth, tenant resolution, CORS
│ │ ├── models/ API types
│ │ └── repository/ all SQL
│ ├── migrations/
│ │ ├── 001_init.sql schema
│ │ ├── 002_seed.sql demo tenants, users, routes, inventory
│ │ └── 003_demo_bookings.sql demo reservations, so the dashboard is alive
│ ├── internal/testsupport/ per-test tenant harness for the Go suite
│ └── .env local credentials (git-ignored)
├── frontend/ Angular application (*.spec.ts alongside source)
├── e2e/ Playwright coded UI tests
│ ├── pages/ one page object per screen
│ ├── fixtures/ shared fixtures and the console-error guard
│ └── tests/ the specs
├── scripts/db.mjs create / migrate / reset the database
└── .github/workflows/ci.yml runs all three suites
```
## Testing
Three suites — 311 tests in total. **[TESTING.md](TESTING.md)** explains how they
are structured; **[TEST-CATALOG.md](TEST-CATALOG.md)** lists every test by name
and is regenerated with `npm run docs:tests`.
```bash
npm run setup # one-time: dependencies and a browser
npm run db:test # one-time: the empty database the Go tests use
npm run test # everything
npm run test:unit # Go + Angular only, no browser
npm run docs:tests # regenerate TEST-CATALOG.md
```
| Suite | Count | What it proves |
| ----- | ----- | -------------- |
| Go | 115 | Tenant isolation, seat accounting and validation against a real PostgreSQL |
| Angular | 114 | Services, guards, the auth interceptor, and component behaviour |
| Playwright | 82 | The whole stack in a real browser, plus 19 simulated-condition tests covering failing, slow and offline backends |
CI runs all three on every push — see [`.github/workflows/ci.yml`](.github/workflows/ci.yml).
## Known limitations
This is a sample, and these are deliberate omissions rather than oversights:
- No migration runner — migrations are applied with `psql` (or `npm run db:migrate`).
- Round trips, seat maps, ancillaries, payment capture, and ticketing are all
out of scope; a booking is a single one-way flight.
- Tenant and user administration is seed-data only; there is no signup or
invite flow.