Author: Saswath Singh
Scope: itinerary-management-backend — 9 custom modules
Stack: NestJS + TypeORM + PostgreSQL (multi-schema) + Redis + S3
Date: June 2026
Trip Terminus is a white-label SaaS platform for travel agencies to create, manage, and sell itineraries. Each travel agency is a tenant that gets its own data-isolated workspace. The backend is a NestJS monolith with a single codebase serving all tenants — isolation is enforced at the database query layer, not via separate databases.
itinerary-management-backend/
├── src/
│ ├── app.module.ts Root module
│ ├── main.ts Entry point (clustering support)
│ ├── app-cluster.service.ts PM2-style cluster manager
│ ├── config/ Typed config (DB, JWT, S3, Redis, etc.)
│ ├── migrations/ TypeORM migrations
│ ├── i18n/en/ Error and success message strings
│ ├── modules/ 40 feature modules (9 authored by us)
│ └── shared/ All cross-cutting concerns
Our 9 modules (all under src/modules/):
| Module | Path | Primary responsibility |
|---|---|---|
| multi-tenancy | multi-tenancy/ |
Tenant lifecycle, subscriptions, webhooks |
| itinerary | itinerary/ |
Core trip package CRUD + AI generation + PDFs |
| days | days/ |
Day-by-day structure within an itinerary |
| hotel | hotel/ |
Hotel inventory + mapping to itinerary/days |
| flight | flight/ |
Flight inventory + mapping to itinerary/days |
| booking | bookings/ |
Customer booking flow + payments + vouchers |
| collection | collection/ |
Homepage content grouping of itineraries |
| seo | seo/ |
Per-page SEO metadata + dynamic variants |
| tag | tag/ |
Cross-resource labels (flights, hotels, activities) |
HTTP Request
│
▼
TenantMiddleware (src/shared/middleware/tenant.middleware.ts:7)
│ Reads `tenantid` / `tenant-id` header
│ Calls runWithTenant(tenantId, () => next())
│ → Establishes AsyncLocalStorage context for this request
▼
NestJS Guards (in order)
│ CustomRateLimitGuard → checks rate-limit metadata
│ AuthGuard("jwt") → validates Bearer token (staff)
│ AuthGuard("booking-jwt") → validates token (customer)
│ PermissionGuard → checks user.permissions array
▼
Controller
▼
Service → calls getTenantId() anywhere in the call stack
▼
TenantAwareRepository (src/shared/tenant/tenant-aware.repository.ts:16)
│ Automatically injects WHERE tenantId = X on every query
│ Automatically stamps tenantId on every save()
▼
PostgreSQL (multi-schema)
Each domain owns its own PostgreSQL schema. This provides logical domain boundaries at the DB level:
| Schema | Entities |
|---|---|
tenant |
Tenants, TenantDetails, TenantConfig, subscription tables |
itinerary |
Itineraries, ItineraryInfo, ItineraryLocations, ItnActivityTypes, ItineraryCounts, Discount |
day |
Days, DayResourceMapping |
hotel |
Hotels, HotelMedia, HotelAmenityMapping, HotelItineraryMapping, HotelTagMapping |
flight |
Flights, FlightSubDetails, FlightDayMapping, FlightTagMapping |
booking |
Booking, BookingTraveler, BookingPayment, BookingUser, ConfirmationVoucher, TransactionLog |
collection |
HeaderCollection, LandingCollection, and their mappings |
seo |
SeoPage, SeoPageVariant |
tag |
Tags, and all *TagMapping tables |
subscription |
SubscriptionPlan, SubscriptionPlanPrice, SubscriptionPlanDetail, payment history |
This is the most important architectural concept. Understand this deeply before touching any other module.
Multiple travel agencies (tenants) share one database. A query from Agency A must never return data belonging to Agency B. This needs to work automatically — developers should not have to manually add WHERE tenantId = X everywhere.
Step 1 — Request entry (tenant.middleware.ts:7):
// For every request except /multi-tenancy/create, /onboard, /webhook:
const parsedTenantId = Number(req.headers["tenantid"]);
runWithTenant(parsedTenantId, () => next());
Step 2 — AsyncLocalStorage (tenant-context.provider.ts:24):
const storage = new AsyncLocalStorage<TenantStore>();
export const runWithTenant = <T>(tenantId: number, callback: () => T): T => {
return storage.run({ tenantId }, callback);
};
export const getTenantId = (): number => {
return storage.getStore()?.tenantId;
};
AsyncLocalStorage is a Node.js built-in that creates a "thread-local" store that persists through the entire async call chain (Promises, async/await) of a single request. It does not leak between concurrent requests.
Step 3 — TenantAwareRepository (tenant-aware.repository.ts:16):
All module repositories that need tenant isolation extend TenantAwareRepository<T> instead of TypeORM's plain Repository<T>. It overrides:
find() / findOne() / findOneBy() → auto-injects WHERE tenantId = :tenantIdsave() → auto-stamps entity.tenantId = getTenantId() before insertupdate() / delete() with object criteria → auto-appends tenantId filtercreateQueryBuilder() → wraps getMany, getOne, getRawMany, etc. to inject tenant filter before executionKey nuance: The repository checks at construction time whether the entity actually has a tenantId column (hasTenantColumnFlag). If not (e.g., reference tables like Airlines, Airports), it skips the filter entirely — no runtime error.
Bypass routes: multi-tenancy/create, multi-tenancy/onboard, and multi-tenancy/webhook bypass the middleware because they are system-level operations that create tenants (not called in the context of an existing tenant).
Every API request from the frontend must include the tenantid header:
tenantid: 42
Authorization: Bearer <jwt>
The frontend obtains tenantId from GET /multi-tenancy/domain/:hostname on first load (via TenantContext).
All shared code lives in src/shared/. Here is a reference map for future development:
shared/guards/, shared/decorators/)| Name | File | Purpose |
|---|---|---|
PermissionGuard |
guards/permission.guard.ts |
Checks user.permissions against @Permissions(Permission.X) |
OptionalJwtAuthGuard |
guards/optional-auth.guard.ts |
Passes through if no token; sets user if token present |
CustomRateLimitGuard |
guards/custom-rate-limit.guard.ts |
Enforces rate-limit metadata from decorators |
@Permissions() |
guards/permission.decorator.ts |
Sets PERMISSIONS_KEY metadata |
@GetUser() |
decorators/get-user.decorator.ts |
Extracts staff User from JWT request |
@GetBookingUser() |
decorators/get-booking-user.decorator.ts |
Extracts BookingUser from booking-JWT request |
@PublicRateLimit() ... @OtpRateLimit() |
decorators/rate-limit/ |
13 semantic rate-limit decorators |
shared/utility/)| Utility | Key methods |
|---|---|
S3Service |
uploadFile(), removeFile(), getSignedUrl() |
SendMailerUtility |
sendTenantOnboardingSuccessEmail(), sendBookingConfirmationEmail(), etc. — uses Handlebars templates from shared/email_templates/ |
common-functions.methods.ts |
slugify(), reviseFileName(), checkFeatureUsageAvailability(), reduceFeatureRemainingUsage(), updatePricePerPerson(), processHotel(), processActivity(), processFlight() |
throw-exception.ts |
throwException(error) — re-throws caught errors preserving HTTP status |
password-generator.utility.ts |
PasswordGenerator.generateSecurePassword() |
shared/pdf/)Uses Puppeteer (headless Chrome). All PDFs go through src/shared/pdf/puppeteer.ts. Templates are Handlebars (.hbs) files. Used for:
shared/email_templates/)All transactional emails use Handlebars (.hbs) templates. Common header/footer partials in common/. Booking emails have separate admin and customer variants.
shared/custom-validator/, shared/decorators/)Beyond standard class-validator:
@DigitConstraint() — numeric validation with custom bounds@DiscountValue() — validates discount percentages@ArrayElementMaxLength() — max length on individual array items@IsNotPastDate() / @IsPastDate() — date boundary validators@FormatToArray() / @TransformNumArr() — transformation decoratorsPath: src/modules/multi-tenancy/
Controller prefix: /multi-tenancy
This module owns the entire tenant lifecycle: creation, onboarding, subscription management, asset uploads, and webhook processing from the billing system. It is the most complex module in the codebase.
| Entity | Schema | Description |
|---|---|---|
Tenants |
tenant.tenants |
Core record: domain, slug, ttId, status, isStandAlone, themeId |
TenantDetails |
tenant.tenant_details |
JSONB-rich: contactDetails, smtpSettings, modules, themeSetup, socialMedia, cmsPages, isB2B |
SubscriptionPlanDetail |
subscription.* |
Active subscription, links to plan and price |
SubscriptionPaymentHistory |
subscription.* |
All payment records |
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | / |
JWT (Admin) | List all tenants with details |
| GET | /details |
JWT + TENANT permission |
Current tenant's details |
| GET | /subscription |
JWT + TENANT permission |
Active subscription info |
| GET | /domain/:domain |
Public | Lookup tenant by domain — used by frontend on boot |
| POST | /create |
JWT (Admin) | Create tenant (no subscription) |
| POST | /onboard |
JWT (Admin) | Create tenant + subscription + admin users |
| POST | /bulk-onboard |
JWT (Admin) | Import from Excel/CSV |
| PUT | /details |
JWT + TENANT permission |
Update tenant settings + assets |
| PUT | /assets |
JWT (Admin) | Upload logo/favicon to S3 |
| DELETE | /domain/:domain |
JWT (Admin) | Hard delete tenant + all data + S3 cleanup |
| POST | /webhook |
Public | Receive billing system events (V2 unified webhook) |
| GET | /check-feature-usage |
Public | Feature gate check |
| PATCH | /reduce-feature-usage |
Public | Decrement feature counter |
| POST | /send-tenant-emails |
JWT (Admin) | Trigger transactional emails |
| POST | /reset-tenant-password |
JWT (cookie) | Password reset |
onboardTenant())This is the most complex flow in the entire codebase. It does all of the following inside a single DB transaction:
1. Validate domain uniqueness, plan existence
2. Create Tenants record
3. Create TenantDetails record (JSONB config)
4. Create SubscriptionPlanDetail + SubscriptionPlanDetailAddOn
5. Copy master data from TRIP_TERMINUS seed tenant:
- ActivityTypes (tenant-scoped copy)
- TripCategories (tenant-scoped copy)
- SeoPages (tenant-scoped copy with variants)
- Amenities (tenant-scoped copy)
- CmsPages (tenant-scoped copy)
6. Seed RBAC: Roles + Permissions + RolePermission mappings
7. Create default admin User for the tenant
8. Register tenant in Lead Management microservice (HTTP call)
9. Send onboarding success email via SendMailerUtility
If any step fails, the transaction rolls back entirely.
handleWebhookV2())The billing system sends all events to one endpoint (POST /webhook) using a discriminated union envelope:
// WebhookV2Dto.eventType determines which handler runs:
"tenant_create" → handleTenantCreateWebhook()
"tenant_update" → handleTenantUpdateWebhook()
"tenant_status_update" → handleTenantStatusUpdateWebhook()
"subscription_payment_initiated" → handleSubscriptionPaymentInitiated()
"subscription_payment_processed" → handleSubscriptionPaymentProcessed()
"subscription_cancel" → handleSubscriptionCancelWebhook()
Subscription plans cap usage (e.g., max 50 itineraries, max 5 admin users). Two utility functions in common-functions.methods.ts handle this:
checkFeatureUsageAvailability(tenantId, featureSlug) — returns true if usage is within limitsreduceFeatureRemainingUsage(tenantId, featureSlug) — decrements the counter after successful creationThese are called at service level before any create operation.
Every write operation has two DTO files:
create-tenant.dto.ts — class-validator decorators, used at runtimecreate-tenant.api-body.ts — Swagger @ApiProperty decorators, used for documentationThis separation keeps validation clean and Swagger docs accurate without mixing concerns.
Path: src/modules/itinerary/
Controller prefix: /itinerary
The core module. An Itinerary (also called "Trip" in the UI) is the central entity around which everything else — hotels, flights, days, activities, bookings — revolves.
ItinerariesSchema: itinerary.itineraries
Important fields:
| Field | Type | Purpose |
|---|---|---|
name |
string | Trip title |
token |
UUID | Public-facing shareable identifier (customer preview links use this) |
isPublished |
boolean | Controls whether the trip is visible on the public website |
isTour |
boolean | Distinguishes Tour (fixed date group travel) from custom trip |
isIndependentItinerary |
boolean | Trip not linked to any lead — listed in main catalog |
budget / pricePerPerson |
number | Pricing fields |
currencyType |
enum | Trip's display currency |
discountType / value |
enum + number | Applied discount |
travelerDetails |
JSONB | PAX breakdown (adults, children, infants) |
bannerImg |
string | S3 URL |
inclusion[] / exclusion[] |
string array | What's included/excluded in the trip |
paymentPolicy / cancellationPolicy |
text | Policy text |
includesFlights/Meals/Sightseeing/Stays/Transfers |
boolean | UI badges |
deletedAt |
timestamp | Soft delete |
Key relations:
Days[] — day-wise breakdown (see Module: days)HotelItineraryMapping[] — hotels mapped to this tripFlights[] — flights mapped to this tripItineraryInfo + InfoMedia[] — additional rich media (gallery, documents)LandingCollectionMapping[] / HeaderCollectionMapping[] — homepage displayItineraryCounts — view/booking countersLeadItinerary[] — which leads this trip is shared withDiscount — discount configuration| Method | Route | Auth | Description |
|---|---|---|---|
| GET | / |
Public | Customer-facing trip list with filters |
| GET | /agent |
JWT + TRIP |
Agent/admin trip list |
| GET | /by-region |
Public | Filter trips by geographic region |
| GET | /lead/search-trips |
Public | Search trips for lead assignment |
| GET | /:id |
Public | Trip detail (includes days, hotels, flights, activities) |
| GET | /day/:day_id |
Public | Full day detail (used by frontend day drawer) |
| GET | /:id/price-details |
JWT + TRIP |
Pricing breakdown for agent |
| GET | /:id/customer-price-details |
booking-jwt | Pricing for customer |
| POST | / |
JWT + TRIP |
Create itinerary (with banner upload) |
| PUT | /:id |
JWT + TRIP |
Edit itinerary (with banner upload) |
| PUT | /info/:itn_id |
JWT + TRIP |
Update info tab + media (up to 10 files) |
| PATCH | /published-status/:itn_id |
JWT + TRIP |
Toggle publish/unpublish |
| PATCH | /per-person-price |
JWT + TRIP |
Recalculate per-person price |
| POST | /duplicate/:itn_Id |
OptionalJwt | Duplicate trip (works for guests + agents) |
| POST | /import-itinerary |
JWT + TRIP |
Import trip from external source |
| POST | /generate-pdf |
Public | Generate quotation PDF via Puppeteer |
| POST | /send |
JWT + TRIP |
Send trip link via WhatsApp / email |
| POST | /ai/itn-conversion |
Public | Ingest AI-generated trip structure |
| PATCH | /:itineraryId/discount |
JWT + TRIP |
Set/update discount |
| GET | /:itineraryId/discount |
JWT + TRIP |
Get discount |
| GET | /:id/hotel-mappings |
JWT + TRIP |
Get hotel mappings |
| PUT | /:id/travelers |
JWT + TRIP |
Save PAX counts |
| DELETE | /:itn_id |
JWT + TRIP |
Soft delete |
| DELETE | /remove-banner-img/:itn_id |
JWT + TRIP |
Remove banner from S3 |
| DELETE | /:itnId/location/:locId |
JWT + TRIP |
Remove a location from the trip |
| GET | /lead/status/:leadId |
Public | Lead status check |
| GET | /lead/:id |
Public | Lead + itinerary details |
| PUT | /lead/unlink/:id |
JWT + TRIP |
Unlink trip from lead |
| GET | /price-details/:leadId/:tripId |
Public | Lead-specific price detail |
createItinerary())checkFeatureUsageAvailability()Itineraries record (auto-assigned token = randomUUID())ItineraryLocations, ItineraryLabels, ItnActivityTypesdateRangeType is fixed, call insertItineraryDates() to pre-generate day recordsreduceFeatureRemainingUsage()SeoService.createDynamicSeoVariant()duplicateItinerary())Deep copy operation. Copies:
Itineraries record (new token, resets isPublished)Days records → for each day, all Activities (with media, other details, tag mappings)Hotels (with media, amenity mappings, tag mappings) + HotelItineraryMappingFlights (with sub-details, tag mappings) + FlightDayMappingDayResourceMapping records relinked to new IDsThis is implemented via internal interfaces (BatchDuplicateContext, PrepareHotelRelatedEntitiesContext) to keep the method signatures clean.
createItineraryFromAI())Endpoint POST /ai/itn-conversion accepts structured JSON from the AI service (Smart Trip) and ingests it as a full itinerary with days and activities. The AI service is a separate microservice at NEXT_PUBLIC_SMART_TRIP_URL.
updatePricePerPerson())A shared utility function in common-functions.methods.ts. Called whenever any price-affecting change happens (hotel added, flight added, discount applied). Reads all hotel and flight prices mapped to the itinerary and recalculates the per-person price.
Path: src/modules/days/
Controller prefix: /days
Days represent the day-by-day schedule within an itinerary. Every resource (activity, hotel, flight, transfer) is ultimately linked to a Day. This makes days the structural backbone of any itinerary.
DaysSchema: day.days
| Field | Type | Purpose |
|---|---|---|
itnId |
FK → Itineraries | Parent itinerary |
locationId |
FK → Cities | City for this day |
date |
date | Actual calendar date (optional, for fixed-date trips) |
title |
string | Day title |
order |
int | Display order (drag-and-drop sortable) |
Relations:
Activities[] — activities for this dayFlightDayMapping[] — flights assigned to this dayDayResourceMapping[] — polymorphic resource link (hotels, transfers, etc.)DayResourceMapping — Polymorphic Resource LinkDayResourceMapping (day.day_resource_mapping) is a pivot table that links any resource type to a day:
day_resource_mapping
dayId → Days.id
resourceId → Hotels.id OR Transfers.id (polymorphic)
resourceType → enum: "hotel" | "transfer"
This design means a single query can fetch all resources for a day regardless of type.
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | / |
JWT + TRIP |
Add a new day to an itinerary |
| PATCH | /set-days/:itineraryId |
JWT + TRIP |
Batch assign dates and locations to days |
| GET | / |
Public | Get all days for an itinerary (by itn_id or token) |
| GET | /itinerary-details/:itineraryId |
Public | Full day-wise detail with all resources |
| GET | /calender/:itineraryId |
JWT + TRIP |
Calendar-formatted view of days |
| PUT | /sequence |
JWT + TRIP |
Reorder days (drag-and-drop) |
| PUT | /:id |
JWT + TRIP |
Edit a single day (title, date, location) |
| DELETE | /:id |
JWT + TRIP |
Delete a day and all its resources |
getDayWiseItineraryDetails()Heavy aggregation query. For a given itinerary, returns all days with their:
order then createdAt via compareByOrderThenCreatedAt())processHotel() utility)processFlight() utility)processTransfer() utility)All four process* utility functions normalize the raw DB rows into clean frontend-consumable objects.
updateDaySequence()Accepts an ordered array of day IDs. Updates each day's order column atomically. The frontend sends this after a drag-and-drop reorder.
batchUpdateLocationDates() (PATCH /set-days/:itineraryId)Allows updating many days' locationId and date at once. Used when the agent sets travel dates for a fixed-date trip — all days are updated in one request.
Path: src/modules/hotel/
Controller prefix: /hotel
Hotels serve a dual role:
HotelsSchema: hotel.hotels
| Field | Type | Purpose |
|---|---|---|
cityId |
FK → Cities | Hotel's city |
name |
string | Hotel name |
starRating |
int | Star category |
checkInTime/Date / checkOutTime/Date |
string/date | Check-in/out details |
roomType |
enum | Standard, Deluxe, Suite, etc. |
roomPreference |
string | Twin/Double, etc. |
price / currencyType |
number/enum | Per-night rate |
address |
string | Physical address |
tenantId |
int | Tenant isolation |
deletedAt |
timestamp | Soft delete |
Relations:
HotelMedia[] — up to 10 images per hotel (S3 URLs)HotelAmenityMapping[] — mapped amenitiesHotelTagMapping[] — tags for search/filterHotelItineraryMapping[] — which itineraries this hotel appears inDayResourceMapping[] — which days this hotel is assigned toHotelItineraryMapping — The Critical Joinhotel_itinerary_mapping
hotelId → Hotels.id
itineraryId → Itineraries.id
cityId → Cities.id (the city context for this mapping)
isPrimary → boolean (primary hotel for that city in that itinerary)
isPrimary controls which hotel's price is used in per-person price calculations. Each city within an itinerary should have exactly one primary hotel.
| Method | Route | Auth | Description |
|---|---|---|---|
| GET | / |
JWT + HOTEL_INVENTORY |
Paginated hotel inventory |
| GET | /:hotel_id |
JWT + TRIP |
Single hotel details |
| GET | /day/:day_id |
JWT + TRIP |
Hotels assigned to a specific day |
| GET | /itinerary/:itinerary_id |
Public | Hotels for an itinerary (customer-facing) |
| GET | /amenities |
JWT + TRIP |
Amenities list |
| GET | /facilities/amenities |
JWT + TRIP |
Paginated amenities with search |
| GET | /city/hotel-details |
JWT + TRIP |
Pre-existing hotels by city + itinerary |
| POST | / |
JWT + TRIP |
Create hotel (up to 10 images) |
| PUT | /:id |
JWT + TRIP |
Update hotel (up to 10 images) |
| DELETE | /:id |
JWT + TRIP |
Soft delete |
| POST | /map-itinerary |
JWT + TRIP |
Map OR unmap hotel ↔ itinerary |
| POST | /map-day |
JWT + TRIP |
Map OR unmap hotel ↔ day |
| PATCH | /primary |
JWT + TRIP |
Set a hotel as primary for a city in an itinerary |
Both map-itinerary and map-day use a single endpoint that either creates or destroys the mapping:
// MapHotelItineraryDto
{
hotelId: number,
itineraryId: number,
toRemove: boolean // false = create mapping, true = delete mapping
}
This reduces endpoint count and keeps the frontend logic symmetrical.
Path: src/modules/flight/
Controller prefix: /flight
Same dual role as hotels:
FlightsSchema: flight.flights
| Field | Type | Purpose |
|---|---|---|
itineraryId |
FK → Itineraries | Parent itinerary |
fromAirportId / toAirportId |
FK → Airports | Origin/destination |
cabinClassType |
enum | Economy, Business, First |
price / currencyType |
number/enum | Flight price |
notes |
text | Free-form agent notes |
Relations:
FlightSubDetails[] — individual flight segments/legs (multi-stop support)FlightDayMapping[] — which days this flight appears onFlightTagMapping[] — tags for search/filterDayResourceMapping[] — polymorphic day resource linkFlightSubDetails — Multi-Segment FlightsFlightSubDetails
flightId → Flights.id
airlineId → Airline.id
flightNumber string
departureTime timestamp
arrivalTime timestamp
fromAirportId → Airports.id
toAirportId → Airports.id
One Flights record can have multiple FlightSubDetails — this models connecting flights (e.g., BLR → DEL → LHR as one "flight" with two sub-legs).
Airline and Airport data is pre-seeded from SQL dump files in shared/entity/sql/. These are read-only reference tables (no tenantId — they are global). The Airports entity links through AirportLanguage for multi-language display.
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | / |
JWT + TRIP |
Create flight with sub-details |
| GET | /airline |
JWT + TRIP |
Airline search/list |
| GET | /airport |
JWT + TRIP |
Airport search (by name/IATA) |
| GET | /details/:id |
JWT + TRIP |
Flight by ID |
| GET | /:dayId |
JWT + TRIP |
Flights for a specific day |
| GET | /itinerary/:id |
Public | All flights for an itinerary, grouped by day |
| PUT | /:id |
JWT + TRIP |
Update flight |
| DELETE | /:id |
JWT + TRIP |
Delete flight |
| POST | /map-day |
JWT + TRIP |
Map OR unmap flight ↔ day (same toRemove pattern) |
Path: src/modules/bookings/
Controller prefix: /bookings
Manages the entire customer booking lifecycle: creation, payment tracking, status management, cancellation, PDF invoice generation, and the confirmation voucher workflow.
This is the only module with two distinct auth contexts:
| User type | JWT strategy | Guard | Decorator |
|---|---|---|---|
| Staff/admin | jwt |
AuthGuard('jwt') |
@GetUser() |
| Customer (end user) | booking-jwt |
AuthGuard('booking-jwt') |
@GetBookingUser() |
BookingUser is a separate entity (booking.booking_users) from the staff User entity. Customers register/login via booking-jwt endpoints and get a separate JWT scoped to customer actions.
BookingSchema: booking.bookings, PK is UUID (not integer — important for security)
| Field | Type | Purpose |
|---|---|---|
bookingReferenceId |
string | Human-readable ID, format BK-DDMMYY-0001 |
statusId |
enum BookingStatus |
Pending=1, Initiated=2, Failed=3, Confirmed=4 |
paymentStatus |
enum | Payment state |
itineraryId |
FK → Itineraries | The trip being booked |
itineraryDetails |
JSONB | Snapshot of the trip at booking time — immutable price/content record |
priceDetails |
JSONB | Full price breakdown |
startDate / endDate |
date | Travel dates |
email / phoneNumber / countryCode |
string | Customer contact |
billingAddress |
string | For invoice |
gstin / panNumber |
string | Indian tax fields |
cancellationReason |
text | Filled on cancellation |
remarks |
text | Internal agent notes |
tenantId |
int | Tenant isolation |
deletedAt |
timestamp | Soft delete |
Why JSONB for itineraryDetails? The trip content can change after booking (agent edits). The itineraryDetails snapshot preserves exactly what the customer booked and paid for. The invoice and voucher PDFs always render from this snapshot, not the live trip.
Relations:
BookingTraveler[] — PAX records with passport/identification detailsBookingPayment[] — payment records (integrates with Razorpay)TransactionLog[] — all payment gateway eventsConfirmationVoucher (one-to-one) — the formal voucher document| Method | Route | Auth | Description |
|---|---|---|---|
| POST | / |
booking-jwt | Create booking |
| GET | / |
booking-jwt | Customer's own bookings |
| GET | /admin |
JWT + BOOKING |
Admin view all bookings |
| PUT | /:id/status |
JWT + BOOKING |
Update booking status |
| GET | /:bookingId/itinerary-pdf |
booking-jwt | Download quotation PDF |
| GET | /:bookingId/itinerary-pdf/admin |
JWT | Admin download PDF |
| POST | /:id/cancel-request |
booking-jwt | Customer submits cancellation request |
| POST | /:bookingId/cancel |
JWT + BOOKING |
Admin approves cancel + triggers Razorpay refund |
| PUT | /:bookingId/remarks |
JWT + BOOKING |
Save internal remarks |
| GET | /:bookingId/confirmation-voucher |
JWT + BOOKING |
Get voucher data |
| PUT | /:bookingId/confirmation-voucher |
JWT + BOOKING |
Save/update voucher content |
| POST | /:bookingId/confirmation-voucher/media |
JWT + BOOKING |
Upload voucher media |
| DELETE | /:bookingId/confirmation-voucher/:mediaId/media |
JWT + BOOKING |
Delete voucher media |
| POST | /:bookingId/confirmation-voucher/generate-pdf |
JWT + BOOKING |
Generate voucher PDF |
| POST | /:bookingId/confirmation-voucher/send-email |
JWT + BOOKING |
Email voucher to customer |
| GET | /:bookingId/invoice |
booking-jwt | Customer invoice PDF |
| GET | /:bookingId/invoice/admin |
JWT + BOOKING |
Admin invoice PDF |
After a booking is confirmed, the agent fills out a ConfirmationVoucher — a structured document with hotel confirmations, flight details, and any special notes. The workflow:
PUT /:bookingId/confirmation-voucher — agent saves structured voucher dataPOST /:bookingId/confirmation-voucher/media — agent uploads supporting documents (max file size and allowed MIME types defined in interfaces/voucher.interfaces.ts)POST /:bookingId/confirmation-voucher/generate-pdf — Puppeteer renders the voucher to PDFPOST /:bookingId/confirmation-voucher/send-email — sends PDF via SendMailerUtility using confirmation-voucher.hbs templateFormat: BK-DDMMYY-NNNN (e.g., BK-030126-0042). Generated in shared/helper/booking-reference-id-generator.ts. The sequential suffix is determined by counting existing bookings for the tenant on that date.
Path: src/modules/collection/
Controller prefix: /collection
Collections control what appears on the tenant's public homepage. They are editorial groupings of itineraries. There are two flavors:
| Type | Entity | Public usage |
|---|---|---|
| Header collection | HeaderCollection |
Navigation/header featured trips |
| Landing collection | LandingCollection |
Homepage sections (banners, scrollers, map view) |
Header collection types (HeaderCollectionType):
ActivityWise — groups trips by activity typeCategoryWise — groups trips by categoryCustom — manually curated listLanding collection types (LandingCollectionType):
ScrollerBanner — horizontal scrolling bannerCountryMapView — geographic map displayActivityWise — activity-grouped sectionBanner — static full-width banner| Entity | Schema | Description |
|---|---|---|
HeaderCollection |
collection.header_collection |
Name, type, isActive |
HeaderCollectionMapping |
(join) | HeaderCollection ↔ Itineraries |
HeaderCollectionSub |
(join) | Sub-collections under a header |
LandingCollection |
collection.landing_collection |
Name, type, isActive |
LandingCollectionMapping |
(join) | LandingCollection ↔ Itineraries |
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | /header |
JWT + COLLECTION |
Create header collection |
| GET | /header |
Public | Get all header collections with trip mappings |
| GET | /header/itinerary-list/:headerCollectionId |
JWT + COLLECTION |
Trip list for header collection |
| PUT | /header/:id |
JWT + COLLECTION |
Update header collection |
| PATCH | /header/:id/status |
JWT + COLLECTION |
Toggle active status |
| DELETE | /header/:id |
JWT + COLLECTION |
Delete header collection |
| POST | /landing |
JWT + COLLECTION |
Create landing collection |
| GET | /landing |
Public | Get all landing collections with trip mappings |
| GET | /landing/itinerary-list/:landingCollectionId |
JWT + COLLECTION |
Trip list for landing collection |
| PUT | /landing/:id |
JWT + COLLECTION |
Update landing collection |
| PATCH | /:id/status |
JWT + COLLECTION |
Toggle active status |
| DELETE | /:id |
JWT + COLLECTION |
Delete |
The frontend TenantContext fetches both GET /collection/header and GET /collection/landing on boot. Public homepage components (Template1Home, Template2Home, etc. per theme) receive the collection data as props and render the corresponding UI sections.
When an agent creates/edits a collection and maps itineraries to it, those trips immediately appear in the relevant homepage section on next page load (no caching).
Path: src/modules/seo/
Controller prefix: /seo
Provides per-page SEO metadata (title, description, keywords, Open Graph, robots) for every public page. Also drives sitemap.xml generation.
| Entity | Schema | Description |
|---|---|---|
SeoPage |
seo.seo_page |
Named page with default meta: name, slug, metaTitle, metaDescription, keywords[], ogImage, isDefault, robotIndex |
SeoPageVariant |
(child) | URL-level override of meta fields; links SeoPage to a specific itineraryId, locationType, locationId |
On tenant creation (onboardTenant()), the TRIP_TERMINUS master tenant's SeoPage records are copied for the new tenant. This seeds all standard pages (home, search, trip-details, contact-us, about-us, privacy-policy, terms) with default meta values.
| Method | Route | Auth | Description |
|---|---|---|---|
| POST | /page |
JWT | Create SEO page (with ogImage upload) |
| GET | /page |
Public | List all SEO pages |
| GET | /page/:id |
Public | Get page by ID |
| GET | /page/slug/:slug |
Public | Get page by slug — used by frontend generateMetadata() |
| PUT | /page/:id |
JWT | Update page (with ogImage upload) |
| DELETE | /page/:id |
JWT | Soft delete |
| POST | /variant/dynamic |
JWT | Create dynamic SEO variant for an itinerary/location URL |
| GET | /sitemap |
Public | All indexable variants for sitemap.xml |
| GET | /active-pages-with-variants |
Public | All active pages with their variants |
When a new itinerary is published, SeoService.createDynamicSeoVariant() is called automatically (triggered from ItineraryService.createItinerary() and DaysService). It creates a SeoPageVariant linked to the itinerary's trip-details SeoPage with auto-generated meta based on the trip name/location.
This is the programmatic SEO strategy — every itinerary automatically gets a properly optimized SEO page variant without manual intervention.
Every public Next.js page uses generateMetadata():
// In every public page:
const seoData = await fetch(`/seo/page/slug/${SeoPageSlugEnum.HOME}`, {
headers: { tenantid: tenantId }
});
return {
title: seoData.metaTitle,
description: seoData.metaDescription,
keywords: seoData.keywords,
openGraph: { images: [seoData.ogImage] },
robots: { index: seoData.robotIndex, follow: seoData.robotFollow }
};
Path: src/modules/tag/
Controller prefix: /tag
Tags are cross-resource labels used for searching and filtering. A single Tag record can be attached to multiple resource types via separate mapping tables.
TagSchema: tag.tags
| Field | Purpose |
|---|---|
name |
Tag label |
isActive |
Active state |
tenantId |
Tenant isolation |
Tag mappings (all in tag schema):
ActivityTagMapping — tag ↔ activityFlightTagMapping — tag ↔ flightHotelTagMapping — tag ↔ hotelLeadTagMapping — tag ↔ leadTransferTagMapping — tag ↔ transfer| Method | Route | Auth | Description |
|---|---|---|---|
| GET | / |
JWT + TRIP |
Paginated tag list with search |
Tags are not created via this endpoint — they are created implicitly when a user types a new tag while creating/editing a flight or hotel. The frontend TagInputWithSuggestion component handles both:
tagData: [{ tagId, toAdd: true }]tags: ["New Tag Name"]The respective service (flight, hotel) handles tag creation and mapping in the same transaction as the resource save.
Understanding these patterns is essential for debugging full-stack issues.
File: itinerary-management-frontend/src/utils/axiosInterceptor.tsx
A singleton Axios instance with two interceptors:
Request interceptor:
token cookie → sets Authorization: Bearer <token>tenantId from TenantContext → sets tenantid: <id> headerx-no-loader: true header present)Response interceptor:
x-token header in response → rotates the JWT cookie (sliding session)401 → clears cookies, redirects to login pageSilent requests (no spinner) use { headers: { 'x-no-loader': 'true' } }. Used for voucher uploads, invoice downloads, and other background operations.
NEXT_PUBLIC_BACKEND_URL → itinerary-management-backend (our modules)
NEXT_PUBLIC_SMART_TRIP_URL → AI/Smart Trip microservice
NEXT_PUBLIC_LEAD_MANAGEMENT_BACKEND_URL → Lead management microservice
The Lead Management microservice gets a different tenantId header injected (from leadTenant.id in TenantContext, not the main tenantId).
No Redux/Zustand. All state is React Context API + local useState.
Key contexts:
| Context | Responsibility |
|---|---|
TenantContext |
Boot-time tenant config from GET /multi-tenancy/domain/:hostname |
SettingsContext |
Global spinner state — read/written by Axios interceptor |
ListingPageContext |
Admin trip list page state (selected trip, lead context) |
PageContext |
Public page data (languages, currencies, user) |
On first render, TenantContext calls GET /multi-tenancy/domain/:hostname. The response includes:
tenantId — injected into every subsequent API requesttheme.slug — determines which theme components render (Theme1...Theme4)modules — list of enabled features (gates via FeatureGuard component)Every public page checks tenantData.theme.slug and renders a theme-specific component:
if (slug === ThemeEnum.THEME_1) return <Theme1TripDetails {...props} />;
if (slug === ThemeEnum.THEME_2) return <Theme2TripDetails {...props} />;
// etc.
Each theme has its own components under components/theme-{1..4}/.
The codebase is a single NestJS application. Domain isolation is achieved via PostgreSQL schema namespacing (hotel.*, flight.*, etc.) rather than microservices. This keeps operational complexity low while maintaining logical domain boundaries.
Trade-off: Easier to deploy and debug than microservices, but scaling individual domains requires scaling the whole app.
TenantAwareRepository over manual tenant filteringEvery WHERE tenantId = X that would otherwise need to be written manually is handled by the base repository. This eliminates an entire class of data-leak bugs. The only places that bypass this are the multi-tenancy module's own repository (which uses plain Repository<Tenants> by design) and global reference tables (airlines, airports, cities).
Each write operation has:
.dto.ts — class-validator decorators for runtime validation.api-body.ts — @ApiProperty Swagger decorators for documentationThis separation prevents validation logic from getting cluttered with Swagger metadata and keeps DTOs readable. If you add a new field, update both files.
AppResponse envelopeAll controllers return Promise<AppResponse>:
interface AppResponse {
message: string; // i18n key, e.g. "SUC_ITN_CREATED"
data: any;
}
Message strings are resolved from src/i18n/en/success.json and error.json. This makes the API response shape predictable for the frontend.
toRemove flag for mapping endpointsHotel, flight, and transfer mapping endpoints use a single endpoint with a toRemove: boolean flag rather than separate POST/DELETE endpoints for mapping. This mirrors the frontend's toggle behavior (same button creates/removes a mapping).
Booking.itineraryDetails (trip snapshot at booking time), TenantDetails.modules (feature flags), and Itineraries.travelerDetails (PAX breakdown) all use PostgreSQL JSONB. The key insight: these fields either change as a unit or need flexible schema. JSONB avoids schema migrations for config changes.
Feature usage limits (checkFeatureUsageAvailability) are enforced in service methods before the actual create operation, not in guards or middleware. This means the limit check and the decrement are close to each other in code, reducing the chance of forgetting to decrement.
multi-tenancy ──creates──→ Seo (seed pages on onboard)
──creates──→ Subscription (plan detail on onboard)
itinerary ──owns──→ days (Days belong to Itineraries)
──maps──→ hotel (via HotelItineraryMapping)
──maps──→ flight (Flights.itineraryId)
──triggers──→ seo (createDynamicSeoVariant on create)
──triggers──→ collection (updates mappings)
──resolves to──→ booking (Booking.itineraryId)
days ──hosts──→ activities (Activities.dayId)
──hosts──→ hotel (via DayResourceMapping)
──hosts──→ flight (via FlightDayMapping)
──hosts──→ transfer (via DayResourceMapping)
hotel ──uses──→ tag (HotelTagMapping)
flight ──uses──→ tag (FlightTagMapping)
activities ──uses──→ tag (ActivityTagMapping)
booking ──references──→ itinerary (snapshot in itineraryDetails JSONB)
──has──→ BookingUser (separate from staff User)
collection ──groups──→ itinerary (via mapping tables)
──displayed on──→ public homepage
seo ──attached to──→ itinerary (SeoPageVariant.itineraryId)
──drives──→ Next.js generateMetadata()
──drives──→ sitemap.xml
tag ──attached to──→ hotel, flight, activity, transfer, lead
ItineraryService and DaysService have a circular dependency through SeoService (both trigger createDynamicSeoVariant). This is resolved via NestJS's ModuleRef lazy injection (this.moduleRef.get(SeoService, { strict: false })). If you see ModuleRef usage in a service constructor, this is why.
tenantid headerIf any API call returns 400 ERR_TENANT_ID_REQUIRED, the frontend is not sending the tenantid header. Check:
TenantContext fully initialized before the call?SettingsProvider → AxiosInterceptor)?TenantAwareRepository and raw queriesTenantAwareRepository only intercepts TypeORM QueryBuilder methods. If you write raw SQL via query() or use a plain DataSource query, the tenant filter is NOT applied automatically. You must add WHERE tenant_id = $1 manually.
MultiTenancyRepository uses plain Repository<Tenants>The multi-tenancy module's own repository does NOT extend TenantAwareRepository — it uses plain Repository<Tenants>. This is intentional: it needs to query across all tenants. Do not change this.
token field on Itineraries vs the idItineraries have both id (integer, internal) and token (UUID, public). Customer-facing URLs use token (e.g., the public preview link). Agent-facing admin URLs use id. The GET /days/ endpoint accepts both — pass either itn_id or token as a query param.
isPrimary and price calculationsWhen mapping multiple hotels to an itinerary for the same city, exactly one must have isPrimary = true. updatePricePerPerson() only sums prices from primary hotels. If isPrimary is not set correctly, the per-person price will be wrong.
Booking.id is a UUID (not an integer). All other entities use integer IDs. This is intentional — booking IDs are exposed in URLs and emails, so UUID prevents enumeration attacks. Be careful with TypeScript types when joining bookings to other tables.
When you add a new entity type that should be copied from the master TRIP_TERMINUS tenant to new tenants, you must add the copy logic to MultiTenancyService.createTenant() or onboardTenant(). Otherwise, new tenants will not have that data.
The rate-limit decorators (@PublicRateLimit(), etc.) only set metadata. Enforcement is in CustomRateLimitGuard. If CustomRateLimitGuard is not applied globally or on the controller, the decorator has no effect. Always verify guard setup when adding new public endpoints.
Puppeteer requires Chrome/Chromium. The Dockerfile must install the necessary system dependencies. If PDF generation works locally but fails in production, check the Docker build for missing chromium-browser dependencies.
TRIP_TERMINUS tenant slugTRIP_TERMINUS is the master seed tenant. Its slug value (MultiTenancySlug.TRIP_TERMINUS) is used as the source for master data copies. Never delete this tenant in production.