Trip Terminus — Backend Modules KT Document

Author: Saswath Singh
Scope: itinerary-management-backend — 9 custom modules
Stack: NestJS + TypeORM + PostgreSQL (multi-schema) + Redis + S3
Date: June 2026


Table of Contents

  1. Project Overview
  2. Global Architecture
  3. Multi-Tenancy Infrastructure
  4. Shared Infrastructure (Cross-Cutting)
  5. Module: multi-tenancy
  6. Module: itinerary
  7. Module: days
  8. Module: hotel
  9. Module: flight
  10. Module: booking
  11. Module: collection
  12. Module: seo
  13. Module: tag
  14. Frontend Integration Patterns
  15. Key Design Decisions & Patterns
  16. Inter-Module Dependency Map
  17. Common Pitfalls & Gotchas

1. Project Overview

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)

2. Global Architecture

Request Lifecycle

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)

Database Schema Namespacing

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

3. Multi-Tenancy Infrastructure

This is the most important architectural concept. Understand this deeply before touching any other module.

The Problem

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.

The Solution: AsyncLocalStorage + TenantAwareRepository

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:

Key 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).

Frontend contract

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).


4. Shared Infrastructure (Cross-Cutting)

All shared code lives in src/shared/. Here is a reference map for future development:

Guards & Decorators (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

Utilities (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()

PDF Generation (shared/pdf/)

Uses Puppeteer (headless Chrome). All PDFs go through src/shared/pdf/puppeteer.ts. Templates are Handlebars (.hbs) files. Used for:

Email Templates (shared/email_templates/)

All transactional emails use Handlebars (.hbs) templates. Common header/footer partials in common/. Booking emails have separate admin and customer variants.

Custom Validation (shared/custom-validator/, shared/decorators/)

Beyond standard class-validator:


5. Module: multi-tenancy

Path: src/modules/multi-tenancy/
Controller prefix: /multi-tenancy

Purpose

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.

Key Entities

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

API Reference

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

Key Service Flows

Tenant Onboarding (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.

Webhook V2 (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()

Feature Usage Gating

Subscription plans cap usage (e.g., max 50 itineraries, max 5 admin users). Two utility functions in common-functions.methods.ts handle this:

These are called at service level before any create operation.

DTO Pattern

Every write operation has two DTO files:

This separation keeps validation clean and Swagger docs accurate without mixing concerns.


6. Module: itinerary

Path: src/modules/itinerary/
Controller prefix: /itinerary

Purpose

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.

Key Entity: Itineraries

Schema: 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:

API Reference

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

Key Service Flows

Create Itinerary (createItinerary())

  1. Check feature usage limit via checkFeatureUsageAvailability()
  2. Validate category IDs and activity type IDs
  3. Insert Itineraries record (auto-assigned token = randomUUID())
  4. Insert ItineraryLocations, ItineraryLabels, ItnActivityTypes
  5. If dateRangeType is fixed, call insertItineraryDates() to pre-generate day records
  6. Reduce feature usage counter via reduceFeatureRemainingUsage()
  7. Create dynamic SEO variant via SeoService.createDynamicSeoVariant()

Duplicate Itinerary (duplicateItinerary())

Deep copy operation. Copies:

This is implemented via internal interfaces (BatchDuplicateContext, PrepareHotelRelatedEntitiesContext) to keep the method signatures clean.

AI Trip Generation (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.

Price Calculation (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.


7. Module: days

Path: src/modules/days/
Controller prefix: /days

Purpose

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.

Key Entity: Days

Schema: 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:

DayResourceMapping — Polymorphic Resource Link

DayResourceMapping (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.

API Reference

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

Key Service Flows

getDayWiseItineraryDetails()

Heavy aggregation query. For a given itinerary, returns all days with their:

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.


8. Module: hotel

Path: src/modules/hotel/
Controller prefix: /hotel

Purpose

Hotels serve a dual role:

  1. Inventory — a library of hotel records that an agent creates and reuses across trips
  2. Itinerary mapping — hotels are linked to specific itineraries and to specific days within those itineraries

Key Entity: Hotels

Schema: 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:

HotelItineraryMapping — The Critical Join

hotel_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.

API Reference

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

Mapping Pattern (toRemove flag)

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.


9. Module: flight

Path: src/modules/flight/
Controller prefix: /flight

Purpose

Same dual role as hotels:

  1. Inventory — an agent creates flight records for a trip
  2. Day mapping — flights are linked to specific days

Key Entity: Flights

Schema: 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 — Multi-Segment Flights

FlightSubDetails
  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).

Reference Data

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.

API Reference

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)

10. Module: booking

Path: src/modules/bookings/
Controller prefix: /bookings

Purpose

Manages the entire customer booking lifecycle: creation, payment tracking, status management, cancellation, PDF invoice generation, and the confirmation voucher workflow.

Dual Auth Strategy

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.

Key Entity: Booking

Schema: 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:

API Reference

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

Confirmation Voucher Workflow

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:

  1. PUT /:bookingId/confirmation-voucher — agent saves structured voucher data
  2. POST /:bookingId/confirmation-voucher/media — agent uploads supporting documents (max file size and allowed MIME types defined in interfaces/voucher.interfaces.ts)
  3. POST /:bookingId/confirmation-voucher/generate-pdf — Puppeteer renders the voucher to PDF
  4. POST /:bookingId/confirmation-voucher/send-email — sends PDF via SendMailerUtility using confirmation-voucher.hbs template

Booking Reference ID Generation

Format: 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.


11. Module: collection

Path: src/modules/collection/
Controller prefix: /collection

Purpose

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)

Collection Types (Enums)

Header collection types (HeaderCollectionType):

Landing collection types (LandingCollectionType):

Key Entities

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

API Reference

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

How the Frontend Uses Collections

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).


12. Module: seo

Path: src/modules/seo/
Controller prefix: /seo

Purpose

Provides per-page SEO metadata (title, description, keywords, Open Graph, robots) for every public page. Also drives sitemap.xml generation.

Key Entities

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

Seeding

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.

API Reference

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

Dynamic SEO 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.

Frontend Integration

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 }
};

13. Module: tag

Path: src/modules/tag/
Controller prefix: /tag

Purpose

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.

Key Entity: Tag

Schema: tag.tags

Field Purpose
name Tag label
isActive Active state
tenantId Tenant isolation

Tag mappings (all in tag schema):

API Reference

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:

The respective service (flight, hotel) handles tag creation and mapping in the same transaction as the resource save.


14. Frontend Integration Patterns

Understanding these patterns is essential for debugging full-stack issues.

HTTP Client Architecture

File: itinerary-management-frontend/src/utils/axiosInterceptor.tsx

A singleton Axios instance with two interceptors:

Request interceptor:

  1. Reads JWT from token cookie → sets Authorization: Bearer <token>
  2. Reads tenantId from TenantContext → sets tenantid: <id> header
  3. Shows global spinner (unless x-no-loader: true header present)

Response interceptor:

  1. Hides spinner
  2. If x-token header in response → rotates the JWT cookie (sliding session)
  3. On 401 → clears cookies, redirects to login page

Silent requests (no spinner) use { headers: { 'x-no-loader': 'true' } }. Used for voucher uploads, invoice downloads, and other background operations.

Three Backend Base URLs

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).

State Management

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)

Multi-Tenancy on the Frontend

On first render, TenantContext calls GET /multi-tenancy/domain/:hostname. The response includes:

Theme-Aware Rendering

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}/.


15. Key Design Decisions & Patterns

1. Monolith with schema-based domain separation

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.

2. TenantAwareRepository over manual tenant filtering

Every 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).

3. Dual DTO + ApiBody files

Each write operation has:

This separation prevents validation logic from getting cluttered with Swagger metadata and keeps DTOs readable. If you add a new field, update both files.

4. Unified AppResponse envelope

All 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.

5. toRemove flag for mapping endpoints

Hotel, 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).

6. JSONB for mutable-over-time data

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.

7. Feature gating at the service layer

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.


16. Inter-Module Dependency Map

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

Circular dependency note

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.


17. Common Pitfalls & Gotchas

1. Missing tenantid header

If any API call returns 400 ERR_TENANT_ID_REQUIRED, the frontend is not sending the tenantid header. Check:

2. TenantAwareRepository and raw queries

TenantAwareRepository 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.

3. 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.

4. The token field on Itineraries vs the id

Itineraries 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.

5. Hotel isPrimary and price calculations

When 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.

6. Booking UUID vs integer IDs

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.

7. Master data seeding on tenant creation

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.

8. Rate limiting decorators are documentation, not enforcement

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.

9. Puppeteer PDF generation in production

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.

10. TRIP_TERMINUS tenant slug

TRIP_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.