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,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