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
+107
View File
@@ -0,0 +1,107 @@
-- TravelHub: multi-tenant flight booking schema
-- Strategy: shared database, shared schema, tenant_id discriminator on every
-- tenant-owned table. Reference data (airports, airlines) is global.
CREATE EXTENSION IF NOT EXISTS "pgcrypto";
-- ---------------------------------------------------------------- tenants --
CREATE TABLE IF NOT EXISTS tenants (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug TEXT NOT NULL UNIQUE,
name TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'active'
CHECK (status IN ('active', 'suspended')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- ------------------------------------------------------------------ users --
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
email TEXT NOT NULL,
password_hash TEXT NOT NULL,
full_name TEXT NOT NULL,
role TEXT NOT NULL DEFAULT 'agent'
CHECK (role IN ('admin', 'agent')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- The same email may exist in different tenants; unique only within one.
CONSTRAINT users_tenant_email_key UNIQUE (tenant_id, email)
);
CREATE INDEX IF NOT EXISTS idx_users_tenant ON users(tenant_id);
-- ------------------------------------------------- reference data (global) --
CREATE TABLE IF NOT EXISTS airports (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iata_code CHAR(3) NOT NULL UNIQUE,
name TEXT NOT NULL,
city TEXT NOT NULL,
country TEXT NOT NULL,
timezone TEXT NOT NULL DEFAULT 'UTC'
);
CREATE TABLE IF NOT EXISTS airlines (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
iata_code CHAR(2) NOT NULL UNIQUE,
name TEXT NOT NULL,
logo_url TEXT
);
-- ---------------------------------------------------------------- flights --
-- Inventory is per-tenant: each agency loads its own negotiated fares.
CREATE TABLE IF NOT EXISTS flights (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
airline_id UUID NOT NULL REFERENCES airlines(id),
flight_number TEXT NOT NULL,
origin_airport_id UUID NOT NULL REFERENCES airports(id),
destination_airport_id UUID NOT NULL REFERENCES airports(id),
departure_time TIMESTAMPTZ NOT NULL,
arrival_time TIMESTAMPTZ NOT NULL,
aircraft TEXT NOT NULL DEFAULT '',
cabin_class TEXT NOT NULL DEFAULT 'economy'
CHECK (cabin_class IN ('economy', 'premium_economy', 'business', 'first')),
base_price NUMERIC(10,2) NOT NULL CHECK (base_price >= 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
seats_total INTEGER NOT NULL CHECK (seats_total > 0),
seats_available INTEGER NOT NULL CHECK (seats_available >= 0),
status TEXT NOT NULL DEFAULT 'scheduled'
CHECK (status IN ('scheduled', 'delayed', 'cancelled')),
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT flights_seats_check CHECK (seats_available <= seats_total),
CONSTRAINT flights_times_check CHECK (arrival_time > departure_time)
);
CREATE INDEX IF NOT EXISTS idx_flights_search
ON flights(tenant_id, origin_airport_id, destination_airport_id, departure_time);
-- --------------------------------------------------------------- bookings --
CREATE TABLE IF NOT EXISTS bookings (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
user_id UUID NOT NULL REFERENCES users(id),
flight_id UUID NOT NULL REFERENCES flights(id),
booking_reference TEXT NOT NULL,
passenger_count INTEGER NOT NULL CHECK (passenger_count > 0),
total_amount NUMERIC(10,2) NOT NULL CHECK (total_amount >= 0),
currency CHAR(3) NOT NULL DEFAULT 'USD',
status TEXT NOT NULL DEFAULT 'confirmed'
CHECK (status IN ('confirmed', 'cancelled')),
contact_email TEXT NOT NULL,
contact_phone TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
-- PNR is unique per tenant, not globally.
CONSTRAINT bookings_tenant_ref_key UNIQUE (tenant_id, booking_reference)
);
CREATE INDEX IF NOT EXISTS idx_bookings_tenant_created ON bookings(tenant_id, created_at DESC);
-- ------------------------------------------------------------- passengers --
CREATE TABLE IF NOT EXISTS passengers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE,
booking_id UUID NOT NULL REFERENCES bookings(id) ON DELETE CASCADE,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
date_of_birth DATE,
passport_number TEXT NOT NULL DEFAULT '',
seat_number TEXT NOT NULL DEFAULT ''
);
CREATE INDEX IF NOT EXISTS idx_passengers_booking ON passengers(booking_id);
+121
View File
@@ -0,0 +1,121 @@
-- Demo data for TravelHub. Safe to re-run: every insert is idempotent.
-- Flight departures are generated relative to now(), so seeded inventory is
-- always in the future no matter when you run this.
-- ---------------------------------------------------------------- tenants --
INSERT INTO tenants (slug, name) VALUES
('skyline', 'Skyline Travel'),
('globetrotter', 'Globetrotter Tours')
ON CONFLICT (slug) DO NOTHING;
-- ------------------------------------------------------------------ users --
-- Passwords are bcrypt hashes produced by pgcrypto's crypt()/gen_salt('bf'),
-- which golang.org/x/crypto/bcrypt verifies directly. Demo password for every
-- seeded account is: demo1234
INSERT INTO users (tenant_id, email, password_hash, full_name, role)
SELECT t.id, v.email, crypt('demo1234', gen_salt('bf')), v.full_name, v.role
FROM tenants t
JOIN (VALUES
('skyline', 'admin@skyline.test', 'Ava Chen', 'admin'),
('skyline', 'agent@skyline.test', 'Marcus Reid', 'agent'),
('globetrotter', 'admin@globetrotter.test', 'Priya Nair', 'admin'),
('globetrotter', 'agent@globetrotter.test', 'Tom Okafor', 'agent')
) AS v(slug, email, full_name, role) ON v.slug = t.slug
ON CONFLICT (tenant_id, email) DO NOTHING;
-- --------------------------------------------------------------- airports --
INSERT INTO airports (iata_code, name, city, country, timezone) VALUES
('JFK', 'John F. Kennedy International', 'New York', 'United States', 'America/New_York'),
('LAX', 'Los Angeles International', 'Los Angeles', 'United States', 'America/Los_Angeles'),
('SFO', 'San Francisco International', 'San Francisco', 'United States', 'America/Los_Angeles'),
('LHR', 'Heathrow', 'London', 'United Kingdom', 'Europe/London'),
('CDG', 'Charles de Gaulle', 'Paris', 'France', 'Europe/Paris'),
('FRA', 'Frankfurt am Main', 'Frankfurt', 'Germany', 'Europe/Berlin'),
('DXB', 'Dubai International', 'Dubai', 'United Arab Emirates', 'Asia/Dubai'),
('SIN', 'Changi', 'Singapore', 'Singapore', 'Asia/Singapore'),
('HND', 'Haneda', 'Tokyo', 'Japan', 'Asia/Tokyo'),
('SYD', 'Kingsford Smith', 'Sydney', 'Australia', 'Australia/Sydney'),
('BOM', 'Chhatrapati Shivaji Maharaj', 'Mumbai', 'India', 'Asia/Kolkata'),
('DEL', 'Indira Gandhi International', 'Delhi', 'India', 'Asia/Kolkata')
ON CONFLICT (iata_code) DO NOTHING;
-- --------------------------------------------------------------- airlines --
INSERT INTO airlines (iata_code, name) VALUES
('AA', 'American Airlines'),
('BA', 'British Airways'),
('EK', 'Emirates'),
('SQ', 'Singapore Airlines'),
('LH', 'Lufthansa'),
('AI', 'Air India'),
('UA', 'United Airlines'),
('AF', 'Air France')
ON CONFLICT (iata_code) DO NOTHING;
-- ---------------------------------------------------------------- flights --
-- Each tenant carries its own inventory of the same routes at its own fares:
-- Skyline sells at list price, Globetrotter at a 12% negotiated markup. This
-- is what makes tenant isolation visible in the UI -- same route, different
-- price and different availability depending on who is logged in.
WITH routes(airline, flight_no, origin, dest, dep_hour, dur_hours, cabin, price, seats) AS (
VALUES
('AA', 'AA100', 'JFK', 'LHR', 18, 7, 'economy', 540.00, 180),
('BA', 'BA178', 'JFK', 'LHR', 21, 7, 'business', 2450.00, 40),
('AA', 'AA136', 'LAX', 'JFK', 8, 5, 'economy', 310.00, 200),
('UA', 'UA523', 'SFO', 'JFK', 7, 5, 'economy', 295.00, 190),
('EK', 'EK202', 'JFK', 'DXB', 23, 12, 'economy', 780.00, 350),
('EK', 'EK506', 'DXB', 'BOM', 3, 3, 'economy', 210.00, 300),
('SQ', 'SQ321', 'SIN', 'LHR', 10, 13, 'business', 3100.00, 50),
('SQ', 'SQ001', 'SIN', 'SFO', 9, 15, 'economy', 890.00, 280),
('LH', 'LH400', 'FRA', 'JFK', 13, 8, 'economy', 495.00, 220),
('AF', 'AF006', 'CDG', 'JFK', 11, 8, 'premium_economy', 1150.00, 60),
('AI', 'AI101', 'DEL', 'JFK', 2, 15, 'economy', 720.00, 240),
('AI', 'AI864', 'BOM', 'DEL', 6, 2, 'economy', 85.00, 160),
('BA', 'BA015', 'LHR', 'SYD', 22, 22, 'economy', 1080.00, 260),
('LH', 'LH716', 'FRA', 'HND', 14, 11, 'economy', 640.00, 230)
),
priced AS (
SELECT t.id AS tenant_id,
al.id AS airline_id,
r.flight_no,
o.id AS origin_id,
d.id AS dest_id,
r.cabin,
r.seats,
day.n AS day_offset,
r.dur_hours,
-- Fares drift with departure date so search results look realistic.
ROUND(
(r.price * CASE t.slug WHEN 'globetrotter' THEN 1.12 ELSE 1.00 END)
* (1 + ((day.n % 7) - 3) * 0.035)
, 2) AS price,
date_trunc('day', now()) + (day.n || ' days')::interval
+ (r.dep_hour || ' hours')::interval AS departs
FROM routes r
CROSS JOIN generate_series(1, 21) AS day(n)
CROSS JOIN tenants t
JOIN airlines al ON al.iata_code = r.airline
JOIN airports o ON o.iata_code = r.origin
JOIN airports d ON d.iata_code = r.dest
)
INSERT INTO flights (
tenant_id, airline_id, flight_number, origin_airport_id, destination_airport_id,
departure_time, arrival_time, aircraft, cabin_class,
base_price, currency, seats_total, seats_available
)
SELECT p.tenant_id, p.airline_id, p.flight_no, p.origin_id, p.dest_id,
p.departs,
p.departs + (p.dur_hours || ' hours')::interval,
'Boeing 787-9',
p.cabin,
p.price,
'USD',
p.seats,
-- Nearer departures are fuller, so the UI shows varied availability.
GREATEST(1, p.seats - ((22 - p.day_offset) * 7) % (p.seats / 2))
FROM priced p
WHERE NOT EXISTS (
SELECT 1 FROM flights f
WHERE f.tenant_id = p.tenant_id
AND f.flight_number = p.flight_no
AND f.departure_time = p.departs
);
+122
View File
@@ -0,0 +1,122 @@
-- Demo reservations, so the dashboard shows a trading picture on first run
-- instead of an empty state. Both agencies have sold tickets, at their own
-- fares, on their own inventory.
--
-- The whole file is one guarded block: if the demo references are already
-- present it returns immediately, which keeps seat counts from being
-- decremented twice on a re-run.
DO $$
DECLARE
already_seeded boolean;
BEGIN
SELECT EXISTS (SELECT 1 FROM bookings WHERE booking_reference = 'QX7A2M')
INTO already_seeded;
IF already_seeded THEN
RAISE NOTICE 'demo bookings already present, skipping';
RETURN;
END IF;
-- Rank each tenant's future inventory so the same flights are chosen on
-- every machine, then attach a booking to a handful of them.
CREATE TEMP TABLE demo_picks ON COMMIT DROP AS
WITH ranked AS (
SELECT f.id AS flight_id,
f.tenant_id,
f.base_price,
f.currency,
t.slug,
row_number() OVER (PARTITION BY t.slug
ORDER BY f.departure_time, f.flight_number) AS rn
FROM flights f
JOIN tenants t ON t.id = f.tenant_id
WHERE f.departure_time > now()
),
spec(slug, rn, pnr, pax, email, phone, status) AS (
VALUES
('skyline', 2, 'QX7A2M', 2, 'harper.quinn@example.com', '+1-212-555-0143', 'confirmed'),
('skyline', 7, 'MT4RB9', 1, 'd.oyelaran@example.com', '+1-415-555-0199', 'confirmed'),
('skyline', 15, 'KP2VJ8', 4, 'the.sandovals@example.com','+1-646-555-0117', 'confirmed'),
('skyline', 23, 'RN9WD3', 2, 'a.lindqvist@example.com', '+44-20-7946-0821','confirmed'),
('skyline', 31, 'HB6ZC5', 3, 'marco.bellini@example.com','+39-06-555-0164', 'confirmed'),
('skyline', 44, 'VF3LK7', 1, 'j.nakamura@example.com', '+81-3-5555-0132', 'cancelled'),
('globetrotter', 4, 'TG8MH2', 2, 'priya.raman@example.com', '+91-22-5555-0178','confirmed'),
('globetrotter', 11, 'WS5QN6', 3, 'okoro.family@example.com', '+234-1-555-0155', 'confirmed'),
('globetrotter', 19, 'ZD7XP4', 1, 'l.fontaine@example.com', '+33-1-5555-0190', 'confirmed'),
('globetrotter', 27, 'CJ2YR8', 2, 'sam.whitaker@example.com', '+61-2-5555-0146', 'confirmed')
),
-- Attribute every demo sale to that tenant's agent account.
selling_agent AS (
SELECT DISTINCT ON (tenant_id) tenant_id, id
FROM users
WHERE role = 'agent'
ORDER BY tenant_id, email
)
SELECT r.flight_id,
r.tenant_id,
a.id AS user_id,
s.pnr,
s.pax,
s.email,
s.phone,
s.status,
ROUND(r.base_price * s.pax, 2) AS total_amount,
r.currency
FROM spec s
JOIN ranked r ON r.slug = s.slug AND r.rn = s.rn
JOIN selling_agent a ON a.tenant_id = r.tenant_id;
INSERT INTO bookings (tenant_id, user_id, flight_id, booking_reference,
passenger_count, total_amount, currency, status,
contact_email, contact_phone, created_at)
SELECT p.tenant_id, p.user_id, p.flight_id, p.pnr, p.pax, p.total_amount,
p.currency, p.status, p.email, p.phone,
-- Spread creation times over the past three weeks.
now() - ((row_number() OVER (ORDER BY p.pnr) * 47) || ' hours')::interval
FROM demo_picks p;
-- Give each booking its own travellers from a small pool of names.
INSERT INTO passengers (tenant_id, booking_id, first_name, last_name,
date_of_birth, passport_number)
SELECT b.tenant_id,
b.id,
n.first_name,
n.last_name,
DATE '1970-01-01' + ((n.idx * 1447) || ' days')::interval,
n.passport
FROM (
SELECT bk.id, bk.tenant_id, bk.passenger_count,
row_number() OVER (ORDER BY bk.booking_reference) AS bn
FROM bookings bk
JOIN demo_picks dp ON dp.pnr = bk.booking_reference
AND dp.tenant_id = bk.tenant_id
) b
CROSS JOIN LATERAL generate_series(1, b.passenger_count) AS g(i)
JOIN (VALUES
(0, 'Harper', 'Quinn', 'P1029384'),
(1, 'Milo', 'Vance', 'P5568211'),
(2, 'Ada', 'Okonjo', 'P7741903'),
(3, 'Ben', 'Sorensen', 'P3310927'),
(4, 'Ines', 'Marchetti', 'P9084417'),
(5, 'Theo', 'Nakamura', 'P2276650'),
(6, 'Rania', 'Haddad', 'P6693108'),
(7, 'Oscar', 'Lindqvist', 'P4417789'),
(8, 'Nadia', 'Petrov', 'P8820134'),
(9, 'Caleb', 'Mwangi', 'P3345562'),
(10, 'Sofia', 'Delgado', 'P7719026'),
(11, 'Arjun', 'Raman', 'P5502943')
) AS n(idx, first_name, last_name, passport)
ON n.idx = (b.bn * 5 + g.i) % 12;
-- Confirmed sales consume seats; cancelled ones do not.
UPDATE flights f
SET seats_available = GREATEST(0, f.seats_available - sold.pax)
FROM (SELECT flight_id, SUM(pax) AS pax
FROM demo_picks
WHERE status = 'confirmed'
GROUP BY flight_id) sold
WHERE f.id = sold.flight_id;
RAISE NOTICE 'demo bookings created';
END $$;