API Server
The central data API for all Travel Tamers applications. Built with Hono 4.12 on Node.js, backed by PostgreSQL via Drizzle ORM. Serves the marketing site, Nexus Ops Hub, Groups app, and n8n automation workflows through a unified REST interface with 32 route modules.
Architecture Overview
The API server is the data backbone of the Travel Tamers platform. It manages the primary tt_fresh_db PostgreSQL database (52 tables) and exposes REST endpoints for all CRUD operations across contacts, companies, deals, bookings, trips, payments, analytics, and more.
The server follows a layered architecture: Hono routes handle HTTP, Zod schemas validate all inputs, Drizzle ORM provides type-safe database queries, and custom error classes ensure consistent JSON error responses. External service integrations (Slack, Gmail, Claude AI, Buffer, Unsplash) are all optional and gracefully degrade to console logging when API keys are absent.
Key Principle: All external services are optional in development. When API keys are not configured, the server logs what it would have done instead of failing. This means you can run the full API locally with just a PostgreSQL connection.
- Internal service auth — new
/api/internal/contactsand/api/internal/dealsendpoints usex-internal-serviceheader with timing-safe comparison instead of JWT. Replaces the broken Bearer token auth from TT-API to Nexus. - All 48
as anycasts eliminated — across 21 API files. NewisValidEnum()helper insrc/utils/type-helpers.ts, Drizzle insert/update patterns use$inferInserttypes, enum checks use proper type narrowing. - Webhook form-submit updated —
/webhooks/form-submitnow receives honeypot field and a different payload shape (nameinstead offirstName+lastName). Both/webhooks/form-submitand/webhooks/page-vieware now PUBLIC (skiprequireServiceKeyviaPUBLIC_WEBHOOK_PATHS). - Nexus sync rewritten —
nexus/client.tsusesx-internal-serviceheader instead of Bearer token.nexus/sync.tscalls/api/internal/contactsand/api/internal/deals.
- Hono upgraded to 4.12.7 — 5 CVEs patched (serveStatic path traversal, setCookie injection, and others).
- Webhook Zod validation — all 4 webhook endpoints (
/webhooks/booking-update,/payment,/page-view,/miles-intent) now validate payloads with Zod schemas and return 400 on invalid input. - Booking payment Zod —
POST /bookings/:id/paymentsvalidates withcreateBookingPaymentSchema. - Commission /calculate Zod —
POST /commissions/calculatevalidates withcalculateCommissionSchema(requiresbookingId,vendorId,bookingValueCents). - Commission pagination —
GET /commissionsnow supportspage/limitquery params with pagination metadata in response. - Dockerfile production fix —
CMDusesnode dist/index.jsinstead oftsx src/index.tsfor faster startup and lower memory. - Health check hardened —
/healthnow verifies DB connectivity (SELECT 1) and Redis ping, returns503on failure. - Error middleware — stack traces stripped in production, only shown in development mode.
Request Flow
Request enters through Traefik
Traefik handles SSL termination and routes api.traveltamers.com to the Hono container on port 3100.
Middleware pipeline runs
Request ID, CORS, request logging, and global rate limiting are applied to every request.
Authentication checked
Routes under /api/* require a valid API key. Public routes (miles-chat, page-views, onboarding) skip auth.
Input validated with Zod
Request body and query parameters are parsed through Zod schemas. Invalid input returns 400 with details.
Database query via Drizzle
Type-safe queries are built with Drizzle ORM, supporting pagination, filtering, search, and soft deletes.
PostgreSQL 16
Tech Stack
| Layer | Technology | Version | Role |
|---|---|---|---|
| Framework | Hono | 4.12 | HTTP server, routing, middleware (upgraded from 4.7 to patch 5 CVEs) |
| Runtime | Node.js | ≥20 | Server runtime via @hono/node-server |
| Language | TypeScript | 5.7 | Type safety across all layers |
| ORM | Drizzle ORM | 0.38 | Type-safe SQL queries, migrations, schema |
| Database | PostgreSQL | 16 | Primary data store (tt_fresh_db) |
| Cache | Redis (ioredis) | 7 | Rate limiting, idempotency, caching |
| Validation | Zod | 3.24 | Request body and query validation |
| AI | Anthropic SDK | 0.39 | Claude-powered MilesChat |
| Slack | @slack/web-api | 7.8 | Channel creation, notifications |
| Google APIs | 144 | Gmail API for transactional emails | |
| Error Tracking | Sentry | 10.42 | Unhandled error reporting |
| Testing | Vitest | 3.0 | Unit and integration tests |
Directory Structure
integrations/
├── src/
│ ├── index.ts # Entry point: app setup, middleware, route mounting
│ ├── api/ # 32 Hono route modules
│ │ ├── companies.ts
│ │ ├── contacts.ts
│ │ ├── deals.ts
│ │ ├── bookings.ts
│ │ ├── miles-chat.ts
│ │ └── ... (28 more)
│ ├── db/
│ │ ├── client.ts # PostgreSQL connection (pg + drizzle)
│ │ ├── redis.ts # Redis connection (ioredis)
│ │ ├── seed.ts # Database seed script
│ │ └── schema/ # 12 Drizzle schema modules
│ │ ├── index.ts # Re-exports all schemas
│ │ ├── enums.ts # 27 pgEnum definitions
│ │ ├── companies.ts # companies, contacts, deals, interactions
│ │ ├── bookings.ts # bookings, travelers, documents, preferences
│ │ ├── trips.ts # destinations, offerings, itineraries, client trips
│ │ ├── clients.ts # clients, family groups, loyalty programs
│ │ ├── vendors.ts # vendors, commission rules/entries
│ │ ├── financial.ts # payments, invoices, schedules, touchpoints
│ │ ├── analytics.ts # events, KPIs, automation runs, tasks
│ │ ├── marketing.ts # proposals, emails, social media, assets
│ │ ├── groups.ts # groups, members, milestones
│ │ └── content-management.ts
│ ├── middleware/
│ │ ├── auth.ts # API key + service key auth
│ │ ├── rate-limit.ts # Redis + in-memory rate limiting
│ │ ├── idempotency.ts # Webhook deduplication
│ │ ├── request-id.ts # Unique request ID header
│ │ └── slack-signature.ts # Slack HMAC-SHA256 verification
│ ├── utils/
│ │ ├── errors.ts # Custom error classes
│ │ ├── logger.ts # Structured logger
│ │ ├── validation.ts # Shared Zod schemas
│ │ ├── type-helpers.ts # isValidEnum() helper, type narrowing utilities (R11)
│ │ ├── cache.ts # Redis cache helpers
│ │ ├── date.ts # Date utilities
│ │ └── env.ts # Environment helpers
│ ├── nexus/
│ │ ├── client.ts # Nexus HTTP client (x-internal-service header, R11)
│ │ └── sync.ts # Sync contacts/deals to Nexus via /api/internal/* (R11)
│ ├── webhooks/ # Webhook handler implementations
│ ├── slack/ # Slack command handlers
│ ├── email/ # Email sending utilities
│ ├── ai/ # AI provider integrations
│ └── tasks/ # Background task runner
├── drizzle.config.ts
├── tsconfig.json
└── package.json
Database Schema
The tt_fresh_db database contains 52 tables organized into domain modules.
All tables use UUIDs for primary keys, soft deletes via a
deletedAt timestamp, and store monetary values in cents (integer)
to avoid floating-point precision issues.
CRM & Sales
| Table | Key Columns | Purpose |
|---|---|---|
| companies | name, slug, stage, lead_score, annual_travel_budget, slack_channel_id | Target companies in the sales pipeline |
| contacts | first_name, last_name, email, company_id, lifecycle_stage, lead_score | People associated with companies or standalone leads |
| contact_interactions | contact_id, channel, direction, subject, body | Communication history (email, Slack, phone, etc.) |
| deals | company_id, contact_id, title, stage, amount, probability | Sales opportunities tracked through pipeline stages |
| deal_stage_history | deal_id, from_stage, to_stage, changed_at | Audit trail of deal stage transitions |
| lead_scoring_rules | name, criteria (JSONB), points, category | Configurable rules for automated lead scoring |
Clients & Travelers
| Table | Key Columns | Purpose |
|---|---|---|
| clients | client_type, status, lifetime_value_cents, travel_style, churn_risk_score | Billing/booking entity with travel preferences |
| travelers | first_name, last_name, date_of_birth, passport fields, TSA/Global Entry | Individual traveler profiles with documents and medical info |
| travel_documents | traveler_id, document_type, document_number, expiry_date | Passports, visas, and other travel documents |
| traveler_preferences | cabin_class, seat_preference, dietary, hotel/airline preferences | Detailed travel preferences per traveler |
| client_addresses | client_id, address_type, street, city, country | Home, work, billing, and mailing addresses |
| emergency_contacts | traveler_id, full_name, relationship, phone | Emergency contacts for each traveler |
| family_groups | name, group_type, primary_client_id | Family, couple, or friend travel groups |
| loyalty_programs | traveler_id, program_type, program_name, membership_number, tier | Airline, hotel, cruise loyalty memberships |
Trips & Bookings
| Table | Key Columns | Purpose |
|---|---|---|
| destinations | name, slug, country, region, continent, visa_required, safety_rating | Travel destinations with metadata |
| trip_offerings | name, trip_type, departure_date, price_from_cents, capacity | Available trips from vendors (cruises, resorts, etc.) |
| itinerary_legs | trip_offering_id, day_number, port_or_location | Day-by-day itinerary for trip offerings |
| trips (client_trips) | client_id, name, trip_type, status, start_date, total_cost_cents | A specific client's booked trip |
| bookings | deal_id, status, departure_date, destination, total_price_cents | Individual booking records with vendor confirmations |
| booking_travelers | booking_id, traveler_id, is_primary | Join table: which travelers are on which booking |
Financial
| Table | Key Columns | Purpose |
|---|---|---|
| payments | client_id, amount_cents, payment_type, status, transaction_id | Payment records (deposits, installments, refunds) |
| payment_schedules | trip_id, installment_number, amount_cents, due_date, status | Scheduled payment installments |
| invoices | invoice_number, total_cents, status, line_items (JSONB) | Client invoices with itemized line items |
| commission_entries | booking_id, vendor_id, gross_commission_cents, nexion_split_pct | Commission tracking per booking |
| vendors | name, tier, vendor_type, default_commission_pct | Travel suppliers (cruise lines, hotels, etc.) |
| vendor_commission_rules | vendor_id, trip_type, commission_pct, bonus_pct | Per-vendor commission rate rules |
| vendor_contracts | vendor_id, contract_type, commission_pct, cabins_held | Group block agreements and rate contracts |
Groups & Marketing
| Table | Key Columns | Purpose |
|---|---|---|
| groups | name, status, min/max_guests, departure_date, price_per_person_cents | Group travel bookings (cruises, team offsites) |
| group_members | group_id, contact_id, role, deposit_paid | Members of each travel group |
| proposals | company_id, slug, page_url, total_views, form_submissions | Company-specific proposal pages |
| email_sequences | name, trigger_event, is_active | Automated email drip campaigns |
| email_sends | contact_id, to_email, status, sent_at, opened_at | Individual email send tracking |
| social_media_posts | platform, body, scheduled_for, status, likes, impressions | Social media content pipeline |
Analytics & Operations
| Table | Key Columns | Purpose |
|---|---|---|
| analytics_events | event_name, session_id, page_url, utm_source, properties | Website and app event tracking |
| kpi_snapshots | snapshot_date, metric, value, change_percent | Daily KPI snapshot records |
| automation_runs | automation_id, status, execution_time_ms | n8n workflow execution history |
| scheduled_tasks | task_type, scheduled_for, status, retry_count | Background task queue (emails, etc.) |
| touchpoints | contact_id, channel, direction, subject, occurred_at | All client touchpoints across channels |
| satisfaction_scores | client_id, score_type, score, feedback, would_recommend | NPS and satisfaction survey responses |
| travel_alerts | destination_id, alert_type, severity, title | Travel advisories and destination alerts |
| managed_content | content_type, slug, title, body, status | CMS-managed content for the marketing site |
Enum Types
The schema uses PostgreSQL pgEnum types for constrained fields. These enforce
valid values at the database level and provide TypeScript type safety through Drizzle.
| Enum | Values |
|---|---|
| company_stage | target, researching, aware, engaged, qualified, proposal_sent, negotiating, won, active_client, churned, disqualified, archived |
| deal_stage | lead, qualified, proposal_sent, proposal_viewed, negotiating, verbal_yes, booking_pending, booking_confirmed, traveling, completed, won, lost |
| booking_status | inquiry, quoted, hold, deposit_paid, confirmed, ticketed, in_progress, completed, cancelled, refunded |
| payment_status | pending, partial, paid, refunded, failed |
| commission_status | projected, earned, invoiced, received, split_paid, final |
| trip_type | expedition_cruise, river_cruise, ocean_cruise, group_trip, team_offsite, luxury_resort, adventure, cultural, event_based, custom |
| trip_status | inquiry, planning, quoted, booked, deposited, paid, traveling, completed, cancelled |
| group_status | planning, interest_gauging, open, minimum_reached, closed, confirmed, traveling, completed, cancelled |
| client_type | individual, family, corporate, group |
| client_status | prospect, active, vip, inactive, churned |
| vendor_tier | preferred, premium, standard |
| payment_type | deposit, installment, final_payment, supplement, refund |
| invoice_status | draft, sent, viewed, paid, partial, overdue, void |
| schedule_status | upcoming, due, overdue, paid, waived |
| interaction_channel | linkedin, email, slack, phone, video_call, in_person, website, buddy_chat, form_submission, newsletter_signup |
| lead_source | builtin_list, linkedin_outreach, linkedin_inbound, website_form, buddy_chat, referral, event, cold_email, social_media, organic_search, other |
| touchpoint_channel | slack, email, phone, video, text, linkedin, in_person, website, buddy_chat, mail |
| touchpoint_direction | inbound, outbound |
| email_send_status | queued, sent, delivered, opened, clicked, bounced, failed, unsubscribed |
| social_platform | linkedin, instagram, facebook, x |
| alert_severity | info, warning, critical |
| content_status | draft, published, archived |
| address_type | home, work, mailing, billing, vacation |
| family_group_type | family, couple, friends, corporate_team |
| loyalty_program_type | airline, hotel, cruise, car_rental, rail, trusted_traveler, other |
| contact_role | c_suite, vp, director, hr_culture, office_manager, travel_coordinator, executive_assistant, individual, other |
| notification_channel | slack, email, sms, push |
Authenticated API Routes
All routes under /api/* require a valid API key (via X-API-Key header or
Authorization: Bearer). Each route module follows a standard CRUD pattern with
pagination, search, UUID validation on :id parameters, and Zod validation on request bodies.
| Route Prefix | Endpoints | Description |
|---|---|---|
| /api/companies | GET list, detail • POST create • PATCH update • DELETE |
Company records with stage pipeline, lead scoring, Slack integration |
| /api/contacts | GET list, detail • POST create • PATCH update • DELETE |
Contact profiles with lifecycle stages and engagement scoring |
| /api/deals | GET list, detail • POST create • PATCH update • DELETE |
Sales pipeline deals with stage history tracking |
| /api/clients | GET list, detail • POST create • PATCH update • DELETE |
Client billing entities with lifetime value and travel profiles |
| /api/travelers | GET list, detail • POST create • PATCH update • DELETE |
Individual traveler profiles with documents and medical info |
| /api/bookings | GET list, detail • POST create • PATCH update • DELETE |
Booking records with vendor confirmations and payment tracking. Sub-route POST /:id/payments validates with createBookingPaymentSchema (Zod). |
| /api/trips | GET list, detail • POST create • PATCH update • DELETE |
Trip offerings (destinations, cruises, itineraries) |
| /api/client-trips | GET list, detail • POST create • PATCH update • DELETE |
Client-specific trip records with dates, costs, itinerary items |
| /api/groups | GET list, detail • POST create • PATCH update • DELETE |
Group travel bookings with member management and milestones |
| /api/payments | GET list, detail • POST create • PATCH update |
Payment records (deposits, installments, refunds) |
| /api/payment-schedules | GET list, detail • POST create • PATCH update |
Scheduled payment installments with reminder tracking |
| /api/invoices | GET list, detail • POST create • PATCH update |
Client invoices with line items and delivery tracking |
| /api/commissions | GET list, detail, summary • POST /calculate • PATCH update |
Commission entries with vendor splits and Nexion reconciliation. GET / supports page/limit pagination query params. POST /calculate validates with calculateCommissionSchema (Zod). |
| /api/vendors | GET list, detail • POST create • PATCH update |
Travel supplier profiles with commission structures |
| /api/analytics | GET events, KPIs, dashboard summaries |
Analytics data for the Nexus dashboard |
| /api/social | GET list • POST create • PATCH update |
Social media post management and scheduling |
| /api/touchpoints | GET list • POST create |
Client interaction touchpoints across all channels |
| /api/satisfaction | GET list • POST create |
NPS scores and satisfaction survey responses |
| /api/travel-alerts | GET list • POST create • PATCH update |
Travel advisories and destination alerts |
| /api/activities | GET list • POST create |
Activity tracking and logging |
| /api/family-groups | GET list, detail • POST create • PATCH update |
Family and friend travel groups |
| /api/loyalty-programs | GET list • POST create • PATCH update |
Traveler loyalty program memberships |
| /api/client-addresses | GET list • POST create • PATCH update • DELETE |
Client address management |
| /api/emergency-contacts | GET list • POST create • PATCH update • DELETE |
Traveler emergency contacts |
| /api/content | GET list, detail • POST create • PATCH update |
CMS-managed content (blog posts, page sections, testimonials) |
| /api/intelligence | GET /top-opportunities |
AI-powered trip recommendations for n8n automation workflows |
Public & Internal Routes
Some routes are public-facing (no API key required) and others are reserved for internal service-to-service communication.
Public Routes (No Auth)
| Route | Rate Limit | Description |
|---|---|---|
GET /health |
Global (100/min) | Deep health check: runs SELECT 1 against PostgreSQL and pings Redis. Returns 503 with failing check details if either is unreachable. Docker HEALTHCHECK uses this endpoint every 30s. |
POST /api/miles-chat/* |
20/min per IP | AI-powered travel chat (Claude Sonnet). Validates with Zod, escapes HTML for XSS prevention. |
POST /api/page-views/* |
100/min per IP | Anonymous page view tracking from the marketing site |
POST /api/onboarding/* |
100/min per IP | New client onboarding form submissions |
POST /slack/commands |
Global | Slack slash command handler (verified via HMAC-SHA256 signature) |
POST /slack/events |
Global | Slack event subscription handler (URL verification + event dispatch) |
Internal Service Routes
| Route | Auth | Description |
|---|---|---|
GET /api/internal/contacts |
x-internal-service |
Nexus → TT-API contact sync. Uses INTERNAL_SERVICE_SECRET with timing-safe comparison (not JWT). Supports query params for filtering. |
GET /api/internal/deals |
x-internal-service |
Nexus → TT-API deal sync. Same x-internal-service header auth as contacts. Supports query params for filtering. |
POST /internal/send-email |
Service Key |
Email sending for n8n workflows (supports templates and plain HTML) |
POST /internal/* |
Service Key |
Internal service endpoints for workflow triggers and dead letter management |
POST /webhooks/form-submit |
Public + Idempotency |
Form submissions from proposal pages (creates CRM records + Slack channels). Now receives a honeypot field for spam detection and a unified name field instead of firstName+lastName. Skips requireServiceKey via PUBLIC_WEBHOOK_PATHS. |
POST /webhooks/booking-update |
Service Key + Idempotency |
Vendor booking confirmation/update. Zod-validated with bookingUpdateSchema. Returns 400 on invalid payload. |
POST /webhooks/payment |
Service Key + Idempotency |
Payment notification handler. Zod-validated with paymentSchema (requires bookingId + amountCents). |
POST /webhooks/page-view |
Public + Idempotency |
Proposal page view analytics. Zod-validated with pageViewSchema. Records to analytics_events. Public (skips requireServiceKey via PUBLIC_WEBHOOK_PATHS). |
POST /webhooks/miles-intent |
Service Key + Idempotency |
High-intent detection from Miles chatbot. Zod-validated with milesIntentSchema. Records to analytics_events. |
POST /hooks/groups/* |
GROUPS_WEBHOOK_SECRET | Groups app sync webhooks (separate auth from main webhook system) |
Idempotency: All
/webhooks/*routes enforce idempotency via theX-Idempotency-Keyheader or an auto-generated SHA-256 hash of the request path + body. Duplicate deliveries within 24 hours return409 Conflict. This prevents double-processing when n8n or external services retry webhook calls.
Authentication
The API uses a multi-layer authentication system depending on the route type. All secret comparisons use timing-safe equality checks to prevent timing attacks.
API Key Authentication
Routes under /api/* require a valid key sent via X-API-Key header
or Authorization: Bearer <key>. Valid keys are configured in the
API_KEYS environment variable (comma-separated list).
# Example request
curl -H "X-API-Key: your-key-here" \
https://api.traveltamers.com/api/companies
Authentication Modes
| Mode | Header | Used By |
|---|---|---|
| API Key | X-API-Key or Authorization: Bearer |
Nexus Ops Hub, external integrations |
| Service Key | X-Webhook-Secret |
n8n workflows, internal webhook calls |
| Internal Service | x-internal-service |
Nexus Ops Hub → TT-API (Docker network). Uses INTERNAL_SERVICE_SECRET env var with timing-safe comparison. Replaces the earlier broken Bearer token approach. Powers /api/internal/contacts and /api/internal/deals. |
| Slack Signature | X-Slack-Signature + X-Slack-Request-Timestamp |
Slack commands and events (HMAC-SHA256) |
Dev Mode: When
API_KEYSis not set andNODE_ENVis notproduction, the auth middleware logs a warning but allows requests through. In production, missing keys always return401 Unauthorized.
Rate Limiting
Rate limiting uses Redis INCR + EXPIRE for distributed counting across
server instances. When Redis is unavailable, an in-memory Map provides fallback
protection. Every response includes rate limit headers.
| Tier | Limit | Window | Applied To |
|---|---|---|---|
| Global | 100 req | 1 minute | All routes (baseline protection) |
| General | 100 req | 1 minute | /api/*, /api/page-views/*, /api/onboarding/* |
| MilesChat | 20 req | 1 minute | /api/miles-chat/* |
| Webhooks | 30 req | 1 minute | /webhooks/*, /hooks/groups/* |
| Auth | 10 req | 1 minute | Authentication endpoints |
Response Headers
X-RateLimit-Limit: 100
X-RateLimit-Remaining: 87
X-RateLimit-Reset: 1710280320
Retry-After: 45 # Only when 429 is returned
Input Validation
Every endpoint validates inputs using Zod schemas. Query parameters, path
parameters (UUID format), and request bodies are all parsed through Zod before reaching handler
logic. Invalid input throws a ValidationError which returns a 400 response with details.
Shared Validation Schemas
Common schemas in src/utils/validation.ts are reused across all route modules:
| Schema | Validates |
|---|---|
uuidParam |
Path :id parameters are valid UUIDs (applied to all 23 route files) |
listQuerySchema |
Pagination: page, limit, search, sortBy (with allowlist), sortOrder |
createCompanySchema |
Company creation: required name, optional industry, stage, etc. |
updateCompanySchema |
Partial company update (all fields optional) |
createContactSchema |
Contact creation with email validation, lifecycle stage constraints |
paginationSchema |
Reusable page/limit query params with defaults (used by commissions, bookings, etc.) |
bookingUpdateSchema |
Webhook: booking update payload (requires bookingReference) |
paymentSchema |
Webhook: payment notification (requires bookingId + amountCents) |
pageViewSchema |
Webhook: page view analytics (sessionId, pageUrl, UTM params) |
milesIntentSchema |
Webhook: Miles high-intent detection (sessionId, intentScore, messages) |
createBookingPaymentSchema |
Booking sub-route: payment creation (requires amountCents + paymentMethod) |
calculateCommissionSchema |
Commission calculation (requires bookingId, vendorId, bookingValueCents as UUID/positive int) |
Pattern: Each route module imports shared schemas and may extend them with domain-specific fields. The
sortByparameter uses an allowlist of valid column names to prevent SQL injection through sort field manipulation.
Type Safety (R11)
All 48 as any type casts have been eliminated across 21 API files. The codebase now uses
proper TypeScript patterns throughout:
| Pattern | Location | Details |
|---|---|---|
isValidEnum() |
src/utils/type-helpers.ts |
Generic helper that checks if a value belongs to a Drizzle pgEnum's value set, returning a type-narrowed result. Replaces as any casts on enum comparisons. |
$inferInsert |
All Drizzle insert/update calls | Insert and update patterns now use Drizzle's $inferInsert types instead of as any on the data object. |
| Enum type narrowing | Route handlers with enum filters | Query parameter enum checks use isValidEnum() to narrow the type before passing to Drizzle's eq() function. |
Error Handling
The API uses a hierarchy of custom error classes that extend AppError. The global
error handler in app.onError() catches all exceptions and returns consistent JSON
responses. Unhandled errors are reported to Sentry in production.
| Error Class | HTTP Status | Code | When Used |
|---|---|---|---|
| ValidationError | 400 |
VALIDATION_ERROR | Invalid input from Zod parsing failures |
| AuthError | 401 |
UNAUTHORIZED | Missing or invalid API key |
| NotFoundError | 404 |
NOT_FOUND | Resource not found (entity + identifier in message) |
| ConflictError | 409 |
CONFLICT | Duplicate records, state conflicts, idempotency duplicates |
| RateLimitError | 429 |
RATE_LIMITED | Too many requests (includes retryAfterMs) |
| ExternalServiceError | 502 |
EXTERNAL_SERVICE_ERROR | Slack, Gmail, or other external API failure |
| AppError (base) | 500 |
INTERNAL_ERROR | Unhandled server errors (sanitized in production) |
Error Response Format
{
"error": {
"code": "VALIDATION_ERROR",
"message": "Invalid input",
"details": [
{ "path": ["email"], "message": "Invalid email format" }
],
"requestId": "req_abc123"
}
}
Production Sanitization: In production,
ValidationErrordetails are stripped to avoid leaking schema information. Server errors (500+) return a generic message instead of the actual error. Stack traces are completely stripped in production and only included in development mode. Full details are always logged server-side and sent to Sentry.
Middleware Stack
Middleware runs in declaration order. The stack is designed so that request tracking and security layers run first, before any route-specific logic.
Request ID
Generates or forwards a unique X-Request-Id header for request tracing across services.
CORS
Configurable via CORS_ORIGINS env var. Allows GET, POST, PATCH, DELETE. Exposes X-Request-Id. Max age 3600s.
Request Logging
Logs method, path, status, and duration for every request at DEBUG level.
Global Rate Limit
100 req/min per IP baseline on all routes. Uses Redis with in-memory fallback.
Route-Specific Middleware
Additional rate limits, authentication (requireAuth / requireServiceKey), idempotency, and Slack signature verification.
| File | Purpose |
|---|---|
middleware/auth.ts |
requireAuth (API key), requireServiceKey (webhook secret), optionalAuth, internal service detection |
middleware/rate-limit.ts |
Factory function + 5 pre-configured tiers (global, general, auth, webhook, miles-chat) |
middleware/idempotency.ts |
Webhook deduplication via X-Idempotency-Key or SHA-256 hash. 24-hour TTL. Redis + memory fallback. |
middleware/request-id.ts |
Generates UUID request IDs, forwards existing X-Request-Id headers |
middleware/slack-signature.ts |
HMAC-SHA256 verification of Slack request signatures using SLACK_SIGNING_SECRET |
Development
Commands
# From the integrations/ directory:
npm run dev # tsx watch src/index.ts (hot-reload on file changes)
npm run build # tsc (compile TypeScript to dist/)
npm run start # node dist/index.js (run compiled output)
npm run test # vitest run (execute test suite)
npm run test:watch # vitest (watch mode)
npm run lint # eslint src/
npm run typecheck # tsc --noEmit (type checking without emit)
Database Commands
npm run db:push # Apply schema directly to database (dev)
npm run db:generate # Generate migration files from schema changes
npm run db:migrate # Run pending migrations
npm run db:seed # Seed the database with sample data
npm run db:studio # Open Drizzle Studio (visual DB browser)
Environment Variables
| Variable | Required | Purpose |
|---|---|---|
DATABASE_URL | Yes | PostgreSQL connection string |
API_KEYS | Production | Comma-separated valid API keys |
WEBHOOK_SECRET | Production | Shared secret for webhook auth |
REDIS_URL | No | Redis connection (rate limiting, idempotency) |
SLACK_BOT_TOKEN | No | Slack Bot for channels and messages |
SLACK_SIGNING_SECRET | No | Slack request signature verification |
ANTHROPIC_API_KEY | No | Claude API for MilesChat |
GOOGLE_SERVICE_ACCOUNT_KEY_FILE | No | Gmail API credentials |
SENTRY_DSN | No | Sentry error tracking |
CORS_ORIGINS | No | Allowed CORS origins (comma-separated) |
LOG_LEVEL | No | Logging level (debug, info, warn, error) |
INTERNAL_SERVICE_SECRET | No | Internal service-to-service auth |
GROUPS_WEBHOOK_SECRET | No | Groups app webhook auth |
SLACK_ONBOARDING_WEBHOOK | No | Slack webhook URL for onboarding notifications (R11) |
Key Files
| File | Purpose |
|---|---|
src/index.ts |
Main entry: app setup, all middleware registration, route mounting, error handling, graceful shutdown |
src/db/schema/index.ts |
Re-exports all 12 schema modules (enums, companies, trips, bookings, clients, etc.) |
src/db/schema/enums.ts |
27 pgEnum type definitions used across all tables |
src/utils/errors.ts |
Custom error class hierarchy (AppError, ValidationError, NotFoundError, etc.) |
src/utils/validation.ts |
Shared Zod schemas (uuidParam, listQuerySchema, create/update schemas) |
src/utils/type-helpers.ts |
isValidEnum() helper and type narrowing utilities (R11, replaces all as any casts) |
src/nexus/client.ts |
Nexus HTTP client using x-internal-service header (R11, replaces Bearer token) |
src/nexus/sync.ts |
Syncs contacts and deals to Nexus via /api/internal/* endpoints (R11) |
src/middleware/auth.ts |
API key auth, service key auth, internal service header detection |
drizzle.config.ts |
Drizzle Kit configuration for migrations and studio |
Deployment
The API server runs as a Docker container (tt-api) behind the Traefik reverse proxy.
Deployment is automated through GitHub Actions with rollback support.
Deployment Pipeline
GitHub Actions triggered
Push to main branch or manual dispatch starts the deploy workflow.
GitHub Actions
TypeScript build + tests
npm run typecheck, npm run lint, npm run test, then npm run build compiles to dist/.
Archive transferred to VPS
Built code is sent to the VPS via git archive or tar over SSH.
SSH + SCP
Docker container rebuilt
The tt-api container is rebuilt and restarted using docker-compose.production.yml.
Docker Compose
Health check verification
A request to /health verifies database and Redis connectivity. On failure, the previous container image is restored.
Production Infrastructure
# Docker container: tt-api
# Host: api.traveltamers.com
# Port: 3100 (internal)
# Database: tt_fresh_db (PostgreSQL 16)
# Cache: Redis 7 (shared with other services)
#
# Traefik handles:
# - SSL termination (Let's Encrypt)
# - HTTP → HTTPS redirect
# - Routing api.traveltamers.com → tt-api:3100
#
# Health monitoring:
# - /health endpoint checked every 5 minutes
# - Failures reported to Slack #monitoring channel
Graceful Shutdown
The server listens for SIGINT and SIGTERM signals. On shutdown, it
stops the background task runner, closes the Redis connection, closes the PostgreSQL connection
pool, and then exits. This ensures in-flight requests complete and connections are properly released.
Rollback Safety: Each deployment tags the current Docker image before rebuilding. If the health check fails, the deploy script automatically rolls back to the previous image. The previous
dist/directory is also preserved asdist-old/for manual recovery.
Production Dockerfile: The production image uses a multi-stage build (Node 20 Alpine). The final
CMDrunsnode dist/index.js(compiled JavaScript) instead oftsx src/index.tsfor faster startup and lower memory usage. Source files are still included for migrations and seeding viatsx. The container runs as thenodeuser (non-root) with a built-inHEALTHCHECKevery 30 seconds.