GolfKitTechnical Reference
This page covers the technical detail behind GolfKit as implemented in the current codebase: architecture, caching mechanics, booking orchestrator, product catalog, webhook system, data model, resilience patterns, and deployment. The platform has been in active development since January 2026 and is deployed to golfkit.io. For the product thinking and design rationale, start with the narrative.
System Overview
The platform is built as six services. Each separation exists for a specific reason: package and commerce logic lives inside the middleware but is handled by its own route layer, keeping it isolated without the overhead of a separate service. The email service is a standalone webhook consumer. It exists partly to send notifications, but also as a working proof that the webhook system is genuinely usable by external integrators. The booking reminder service is independent because its scheduling concerns have nothing to do with booking or cache logic. The Stripe gateway is isolated to contain payment webhook failures away from the booking API.
Tech Stack
Core framework & runtime
| Technology | Purpose |
|---|---|
| Node.js 20 | Runtime (Alpine-based production image) |
| Express | HTTP framework for middleware and companion services |
| TypeScript (strict) | Type safety across all services |
Data & state
| Technology | Purpose |
|---|---|
| Redis 7 (ioredis) | Cache, pub/sub, booking locks, demand counters, write-ahead log |
| PostgreSQL 16 | Durable audit trail, configuration, event store |
| Knex.js | SQL query builder and migration runner |
Integrations
| Technology | Purpose |
|---|---|
| Lightspeed Golf API v2 | Upstream system of record for tee times, reservations, and payments |
| Stripe | Payment processing with per-tenant credentials |
| Nodemailer | Transactional email delivery (email-service) |
| Render | Custom domain provisioning with automatic TLS |
| Supabase | OAuth backend for admin portal (optional) |
Quality & monitoring
| Technology | Purpose |
|---|---|
| Jest + Supertest | Integration and unit testing (110 test files) |
| Sentry | Error tracking across all services |
| Docker Compose | Local development and CI orchestration |
API Surface
The middleware exposes over 200 route handlers. Guest-facing routes are designed for zero-authentication access: no login required to browse, book, manage, or check in. Admin routes resolve scope once at the boundary via AdminScope, so downstream code never re-checks authorisation.
Guest-facing
| Endpoint | Purpose |
|---|---|
GET /teetimes/:orgId/:date | SWR tee times with X-Cache headers (hit/miss/stale) |
GET /availability/:orgId | Date-range availability summary (multi-date batch) |
GET /changes/:orgId/:date | SSE stream of real-time tee-time changes |
GET /items | Mode-aware product catalog (passthrough or customised) |
POST /book | Multi-system booking (Lightspeed + adapters) |
DELETE /book/:reservationId | Cancellation with compensation and refund settlement |
POST /manage | Guest self-service lookup (email + reference) |
POST /waiting-list/:orgId/join | Guest joins day-based waiting list |
POST /storefront/:orgId/checkin/lookup | Kiosk check-in lookup (email or reference) |
POST /storefront/:orgId/checkin | Record check-in (person + reservation level) |
GET /customer-account/bookings/:orgId | Authenticated customer: booking history for the venue |
POST /customer-account/link-booking | Link an existing booking to a customer account |
GET /booking/payment-config | Payment configuration for the checkout flow |
Admin operations
| Endpoint | Purpose |
|---|---|
/admin/bookings/:orgId/log | Booking audit trail with filtering and pagination |
/admin/cancellations/:orgId/log | Cancellation history with refund details |
/admin/notices/:orgId/:date | Notice CRUD, day/time blocking, bulk cancellation |
/admin/waiting-list/:orgId | Waiting list inspection, config, conversion tracking |
/admin/catalog/:orgId | Product curation: items, categories, sync, course mappings |
/admin/webhooks | Destination CRUD, delivery log, secret rotation |
/admin/tenants/:tenantId/credentials | Encrypted credential management |
/admin/theme/:tenantId | Per-venue branding configuration |
/admin/storefront/:orgId/slug | Custom slug management |
/admin/storefront/:orgId/hostname | Custom domain + TLS provisioning |
/admin/payment-settings/:orgId | Stripe keys, refund mode, tier policy |
/admin/feature-governance/:orgId | Feature toggle management with usage tracking |
/admin/pricing/:orgId | Price override rules (absolute, percentage, or fixed; date/time/player-type scoped) |
/admin/reminders/:orgId | Booking reminder lead-time configuration (proxied to reminder service) |
/admin/revenue/:orgId | Revenue dashboard: bookings, gross, refunds, net for a date range |
/admin/reports/:orgId | Saved revenue report definitions and results |
/admin/reconciliation/:orgId | Reconciliation between platform records and upstream system |
/admin/teesheet/:orgId | Admin tee-sheet view with operational overlays |
/admin/customers/:orgId | Customer contact management and booking history lookup |
/admin/courses/:orgId | Per-org course display configuration |
/admin/pause/:orgId | Pause mode toggle (replaces booking page with custom content) |
/admin/auth/me | Current admin scope introspection |
System
| Endpoint | Purpose |
|---|---|
GET /health | Service health check (Redis, Postgres, API) |
GET /metrics | In-process metrics snapshot |
POST /invalidate | Manual cache invalidation trigger |
Caching Architecture
The caching layer exists because of a hard constraint: Lightspeed enforces ~200 requests/minute. Without caching, a busy resort with dozens of concurrent browsers would exhaust the budget in seconds, leaving nothing for bookings. The worst failure mode (background polling crowding out real bookings) is what the budget governor is specifically designed to prevent. See the narrative for the product rationale.
Redis data structure
| Key pattern | Type | Purpose |
|---|---|---|
teetimes:{orgId}:{date} | Hash | Tee-time data (JSON per slot) |
avail:{orgId}:{date} | String | Availability summary sidecar |
poll_meta:{orgId}:{date} | Hash | Last polled timestamp, page count, interval (7-day TTL) |
teetime_status:{teetimeId} | String | Booking lock (SET NX, 120s TTL) |
demand:{orgId}:{date} | Counter | Request count in 10-minute sliding window |
demand_dedup:{orgId}:{date}:{ip} | String | Per-IP dedup (10s TTL) |
Date tiering (8 tiers)
| Tier | Day offset | Behaviour |
|---|---|---|
| 0 | Today | Shortest TTL, most frequent polling |
| 1 | Tomorrow | Near-real-time freshness |
| 2 | 2–7 days | Active booking window |
| 3 | 8–14 days | Near-term lookahead |
| 4 | 15–30 days | Medium-term |
| 5 | 31–60 days | Background freshness |
| 6 | 61–90 days | Infrequent polling |
| 7 | 90+ days | On-demand only (Infinity interval) |
Demand multipliers
final_ttl = max(tier.minTTLSec, round(tier.baseTTLSec × multiplier))
cold: 1.0× warm: 0.5× hot: 0.25× peak: minTTL floor
Budget governor
The governor runs a 1-second tick loop that continuously answers: how many Lightspeed calls can I safely spend right now, while keeping headroom for bookings?
budget_per_min = rate_limit_ceiling − total_api_calls − safety_margin
budget_per_sec = floor(budget_per_min / 60)
shedding_threshold: 0.8 – 2.0 (self-adjusting)
The poll scheduler uses a min-heap priority queue ordered by next_poll_at. Cold-start polls are staggered across 3 minutes (immediate for today/tomorrow, up to 180s for dates 61–90 days out). A hard concurrent poll cap of 5 prevents resource exhaustion.
Engineering note: The system uses two independent TTL mechanisms: a request TTL (how stale data can be when a user hits the endpoint, dynamically shrinks with traffic) and a polling TTL (background safety net regardless of traffic). Coupling these would force a choice between wasting budget on quiet dates and risking stale data when traffic resumes. Splitting them lets each serve its purpose independently.
Real-time stream (SSE)
| Property | Value |
|---|---|
| Max connections | 100 (global cap, 503 when exceeded) |
| Heartbeat | 30 seconds (: ping comment) |
| Client retry | 3,000ms |
| Multiplexer | Shared Redis subscription per channel (not per client) |
| Reconnection | connected event instructs full state re-fetch |
Booking Orchestrator
The orchestrator is not a pass-through to Lightspeed. It locks the slot first, validates availability, then calls the upstream API. This ordering matters: if Lightspeed receives a reservation and a downstream system then fails, you have a claimed tee time with no matching package order. Compensation handles this, but avoiding the situation in the first place is better. See the narrative for the design rationale.
Forward path (within 20-second timeout)
Lightspeed adapter (4-step API flow)
// Step 0: Auto-create customer (if name provided, no existing ID)
const customerId = await createCustomer(orgId, { name, email, phone }, tenantId);
// Step 1: Create reservation request
const requestId = await createReservationRequest(orgId, teetimeId, tenantId);
// Step 2: Create round requests (one per player)
for (let i = 0; i < playerCount; i++) {
await createRoundRequest(orgId, requestId, { playerTypeId, holes, extras, kits }, tenantId);
}
// Step 3: Create reservation (commits the booking)
const reservation = await createReservation(orgId, requestId, tenantId);
// Step 4: Payment confirmation
await confirmPayment(orgId, reservation.id, { amount, reference, customerId }, tenantId);
Compensation (within 10-second timeout)
On partial failure, every completed order is cancelled. The compensation timeout is separate from the forward-path timeout. Without that separation, compensation could hang indefinitely after the forward path has already expired, leaving orphaned reservations. Compensation failures are recorded but do not block the error response.
Adapter interface
interface BookingSystemAdapter {
systemId: string;
placeOrder(items: BookingItem[], context: BookingContext): Promise<OrderResult>;
cancelOrder(orderId: string, context: BookingContext): Promise<void>;
}
interface BookingItem {
productId: string;
systemId: string; // 'lightspeed' | 'packages' | future
qty: number;
unitPrice: number; // minor units (pence/cents)
label?: string;
}
Idempotency state machine
| State | TTL | Action on duplicate request |
|---|---|---|
pending | 120 seconds | Return 409 Conflict |
committed | 24 hours | Return cached success result |
failed | 24 hours | Return cached failure result |
Product Catalog
The catalog exists because the system of record and the guest experience have different needs. Lightspeed’s product list often contains internal items, seasonal products, and names that make sense in the back office but not on a booking page. The curation layer lets admins control what guests see without touching the upstream system. It also abstracts away which system is providing products. The guest API is identical regardless of whether products come from Lightspeed directly, from the curated database, or from a third-party adapter.
Passthrough mode
Products served directly from Lightspeed via Redis cache (30-minute TTL). Lock-based deduplication (fetching:products:{tenantId}:{type}, 15s TTL, NX) prevents thundering herd on cold cache. Spin-wait (75 attempts × 200ms) if lock held by another process, with direct fetch as fallback. Zero admin effort, useful for venues where the back-end catalog is already clean.
Customised mode
Products synced from Lightspeed into storefront_items table. The key invariant: sync never destroys admin curation. Name and price update from the source; display name, description, category, visibility, and featured status are always preserved.
| Admin action | Effect |
|---|---|
| Review inbox | New products arrive as hidden, counter resets on read |
| Publish / unpublish / disable | Controls visibility to guests |
| Set display name & description | Overrides Lightspeed name in guest UI |
| Assign category | Places item in featured / extras / occasions slot |
| Set featured + rank | Pins item to top of section with explicit ordering |
| Acknowledge removal | Clears warning when Lightspeed deletes a product |
| Trigger sync | Immediate re-fetch from Lightspeed (or 12-hour auto) |
Course-specific filtering
Product-to-course mappings in product_course_mappings table. No mappings = available on all courses. With mappings = restricted to those courses only. Applied transparently when ?courseId= parameter is provided.
Notices & Blocking
Golf courses deal with weather, maintenance, corporate events, and partial closures constantly. The notice system gives admins three tools: a message (informational, warning, or alert), a full-day closure toggle, and time-range blocking for partial closures. All three are enforced at the booking boundary. The orchestrator checks notices before attempting any reservation.
Notice data model
interface DailyNotice {
date: string; // YYYY-MM-DD
message: string | null; // admin message to guests
severity: 'info' | 'warning' | 'alert';
unavailable: boolean; // full-day closure
blockedRanges: BlockedRange[]; // partial time blocking
requiresConfirmation: boolean; // guest must acknowledge
}
interface BlockedRange {
from: string; // HH:mm (24-hour)
to: string; // HH:mm (24-hour)
}
Booking enforcement
| Condition | Booking orchestrator behaviour |
|---|---|
unavailable: true | All bookings rejected with date_unavailable |
| Tee time in blocked range | Booking rejected with timeslot_blocked |
requiresConfirmation: true | Booking requires noticeConfirmed flag (audit logged) |
Bulk cancellation
Admin can cancel bookings affected by blocking: scope all (requires unavailable=true) or blocked_only (requires blocked ranges). Sequential cancellation respects rate limits and refund policies. Returns per-booking results with summary counts.
Caching
Notices cached in Redis (notice:{orgId}:{date}, 24-hour TTL) with a sorted set index (notices_index:{orgId}) for range queries. Invalidated on mutation via Redis pub/sub.
Check-In & Kiosk
The check-in system has two levels: reservation-level (triggered once in Lightspeed when the first person arrives) and person-level (tracked individually in the platform’s own checkins table). This distinction matters because Lightspeed treats check-in as a group action, but the venue may want to know exactly who arrived.
Check-in window
1 hour before to 4 hours after tee time. Lookup returns ok, too_early, too_late, or not_found.
Two lookup methods
| Method | Input | Response |
|---|---|---|
| Customer email | All bookings in check-in window for that email | |
| Reference | Booking reference | Single booking with window status |
Check-in levels
| Level | System | Trigger |
|---|---|---|
| Reservation | Lightspeed API | First person to check in triggers group check-in upstream |
| Person | Platform checkins table | Each individual check-in recorded separately |
Check-in also upserts customer_contacts for email capture and dispatches booking.checked_in webhook. Kiosk page auto-resets after 8 seconds.
Rate limiting (in-memory)
| Action | Limit |
|---|---|
| IP lookup | 60 / minute |
| Email lookup | 5 / minute |
| IP check-in | 30 / minute |
| Email check-in | 3 / minute |
Slugs & Custom Domains
Multi-tenancy means each venue needs its own URL space. Two routing mechanisms support this: slugs (human-readable paths on the platform domain) and custom hostnames (the venue’s own domain with automatic TLS). Both resolve to the same tenant and serve the same pages. The guest experience is identical regardless of which mechanism is used.
Slug routing
| Property | Detail |
|---|---|
| Format | 2–50 chars, lowercase alphanumeric + hyphens |
| Reserved | admin, api, health, metrics, static, index |
| Lookup | Redis index tenant_by_slug:{slug} → orgId |
| Routes | /:slug (booking), /:slug/admin, /:slug/check-in, /:slug/manage |
Custom domain routing
| Property | Detail |
|---|---|
| Format | Standard DNS hostname (must contain at least one dot) |
| Reserved | localhost, golfkit.io, golfkit.com, golfkit.net |
| Lookup | Redis index tenant_by_hostname:{hostname} → orgId |
| TLS | Automatic provisioning via Render API (renderDomainId stored per tenant) |
| Wildcard | GolfKit-owned subdomains (*.golfkit.io) use platform wildcard certificate |
Hostname takes precedence: GET / on a matched hostname serves that tenant’s booking widget directly.
Data Model
Redis handles everything that must be fast: tee-time cache, booking locks, demand counters, webhook queue, pub/sub, and the write-ahead log. PostgreSQL handles everything that must be durable: booking audit trails, cancellation records, the events table, webhook delivery logs, and all configuration. The write-through pattern persists to Postgres first (source of truth), then caches in Redis. If Postgres is down, Redis WAL queues the write; if Redis is down, the Postgres insert still succeeds and reads fall back to the database.
Core tables
| Table | Purpose | Key columns |
|---|---|---|
booking_log | Every booking attempt | org_id, teetime_id, result, orders (JSONB), duration_ms |
booking_facts | Denormalised booking data for analytics & guest self-service | tenant_id, reservation_id, customer_email_norm, teetime_date |
cancellation_log | Cancellation audit trail | reservation_id, trigger, refund_id, voucher_issuance_id |
events | Unified append-only event store | tenant_id, event_type, entity_id, event_payload (JSONB) |
checkins | Per-person check-in records | tenant_id, reservation_id, email, source, checked_in_at |
Payment tables
| Table | Purpose |
|---|---|
payments | Stripe payment intents (status, amount, metadata) |
refunds | Refund records linked to payment intents and cancellation log |
voucher_issuances | Voucher correlation records (org, reference, face value) |
org_payment_settings | Per-org refund mode, tier policy, voucher toggle |
org_stripe_credentials | Encrypted Stripe keys per org (AES-256-GCM) |
Operational tables
| Table | Purpose |
|---|---|
daily_notices | Closures, warnings, blocked ranges (soft-delete) |
notice_confirmations | Guest acknowledgement audit trail |
waiting_list | Day-based entries with status and conversion tracking |
waiting_list_configs | Per-tenant feature toggle |
customer_contacts | Email capture from bookings and check-ins |
Catalog tables
| Table | Purpose |
|---|---|
storefront_items | Curated product catalog (synced from Lightspeed, admin-editable) |
storefront_categories | Display categories (featured / extras / occasions) |
catalog_sync_state | Per-org sync metadata (last synced, product count, inbox counter) |
product_course_mappings | Product-to-course restrictions |
Customer account tables
| Table | Purpose |
|---|---|
customer_accounts | Federated identity accounts (provider + subject); one record per identity |
customer_account_links | Links an account to a per-tenant customer record; revocable, one active link per tenant per customer |
tenant_auth_settings | Per-tenant customer auth configuration (provider, enforcement mode, JIT linking toggle) |
Pricing & analytics tables
| Table | Purpose |
|---|---|
golfkit_price_overrides | Per-org price rules: absolute, percentage, or fixed adjustment scoped by date range, time window, and/or player type |
revenue_journal | Double-entry revenue ledger; source of truth for revenue reporting and reconciliation |
Tenant & configuration tables
| Table | Purpose |
|---|---|
cg_venues | Pre-researched venue directory (name, location, website, currency, booking status); populated by running AI-assisted theme extraction across thousands of golf courses in advance, so recognised venues arrive at sign-up with a candidate theme already generated |
tenant_signups | Tenant registration (slug, hostname, status, plan) |
tenant_themes | Per-venue branding (JSONB overrides) |
tenant_admin_keys | Scoped admin API keys (hashed, expirable, revocable) |
credential_audit_log | Credential access trail per tenant |
feature_governance | Feature toggles per tenant with locking |
feature_usage_monthly_mv | Materialised view: monthly usage aggregation for billing |
course_configs / course_overrides | Per-tenant course display configuration |
user_accounts | Admin user accounts with role and org binding |
platform_settings | System-wide configuration (key-value) |
Webhook & email tables
| Table | Purpose |
|---|---|
webhook_configs | Destination definitions (URL, secret, enabled events) |
webhook_delivery_log | Every delivery attempt (status, latency, response body) |
webhook_audit_log | Configuration change audit |
email_connectors | Per-org email provider config (encrypted credentials, status) |
email_event_routes | Per-org routing: which events trigger emails, to whom |
email_templates | Editable templates per org per event type |
email_send_history | Delivery audit trail (status, provider, message ID) |
email_tenant_configs | Tenant-level email provider and webhook config |
Domain event types
All domain events are stored in a single events table with JSONB payloads. New event types do not require migrations. This flexibility is deliberate, because the events table feeds the webhook dispatch system directly and new features should not need schema changes to emit events.
type DomainEventType =
| 'booking_created' | 'booking_failed' | 'booking_cancelled'
| 'cancellation_failed'
| 'voucher_issued' | 'voucher_redeemed' | 'refund_settled'
| 'waiting_list_created' | 'waiting_list_notified'
| 'waiting_list_completed' | 'waiting_list_cancelled'
| 'waiting_list_expired' | 'waiting_list_config_updated'
| 'waiting_list_conversion_evaluated'
| 'teetime_available' | 'feature_state_changed';
Multi-Tenancy & Credentials
Each tenant authenticates to Lightspeed using their own credentials, because resorts should not have to trust a shared token. The AdminScope abstraction decouples authentication (how we know who the caller is) from authorisation (what the caller can access). Today this is resolved from API keys in a database table; tomorrow it could come from JWT claims via Supabase. The scope type is the contract: as long as it is populated, the rest of the system does not care how it was derived.
Admin scope model
type AdminScope =
| { type: 'super' } // platform admin, all orgs
| { type: 'tenant'; orgId: string }; // tenant admin, single org
Credential encryption
| Property | Value |
|---|---|
| Algorithm | AES-256-GCM |
| IV | 12 bytes (random per write) |
| Key derivation | HKDF(SHA-256, rootKey, salt = tenantId) |
| DEK size | 32 bytes |
| Version tracking | 8-char fingerprint of root key per ciphertext |
| Rotation | Set new root key → restart → call rewrap endpoint |
Per-tenant token management
OAuth access tokens stored in Redis (auth:{tenantId}:access_token) with automatic refresh. Process-local memory cache as fallback when Redis is unavailable. Lock contention during refresh handled by polling (up to 10 seconds).
Webhook System
Every domain event flows through a single dispatch pipeline. Operators configure destinations with URL, event filters, and a signing secret; the worker handles queuing, retry, dead-lettering, and delivery logging. The email microservice subscribes via this same mechanism. It receives the same signed payloads that any external integration would.
Queue architecture
| Component | Configuration |
|---|---|
| Queue | webhook_dispatch_queue (Redis list, LPOP/RPUSH) |
| Dead-letter | webhook_dispatch_dead |
| Worker poll | 300ms interval, 25 events/tick, 10 concurrent dispatches |
| Retry | Up to 8 attempts before dead-letter; backoff [0, 200, 600, 1800]ms ±25% jitter |
Signature format (Stripe-compatible)
X-Webhook-Signature: t={unix_timestamp},v1={hmac_sha256_hex}
HMAC input: "{timestamp}.{body_json}"
Dual-secret support during rotation: 24-hour grace period where both current and old signatures are sent.
SSRF protection (two-stage)
Resilience Patterns
Guest-facing and admin-facing code have different failure strategies. If the package catalog is unavailable, the tee-time booking can still complete with an empty extras list. The guest path degrades gracefully because losing an upsell is better than losing a booking. Admin operations surface errors explicitly, because a silent failure in configuration or cancellation management could leave a venue unable to operate.
Circuit breaker (per-tenant)
| Condition | Response |
|---|---|
| 429 rate limit | Wait for retry-after, resume at 80% for 2 minutes, then 100% |
| 5xx error | Exponential backoff: min(1000 × 2^(errors-1), 30000)ms |
| 5 consecutive 5xx | Circuit opens for 60 seconds, auto-closes |
| Success after errors | Error streak cleared immediately |
Write-ahead log
// When Postgres is unavailable, critical writes queue in Redis
try {
await db('events').insert(event);
} catch {
await redis.rpush('write_ahead_log', JSON.stringify({ table: 'events', data: event }));
}
// Replayed in order on Postgres recovery
Implemented by the voucher service, event service, and booking log. WAL depth is monitored and replay progress is tracked.
Observability
A single booking touches the lock, the cache, the Lightspeed API, possibly the package layer, the events table, and the webhook queue. Without correlation, debugging a failure across those boundaries requires guesswork. Every request gets a unique ID that propagates through the entire stack.
Structured logging
JSON-structured logs via AsyncLocalStorage for per-request correlation IDs. Domain-specific convenience methods keep log entries consistent without manual field construction:
logger.demandTransition(orgId, date, 'cold', 'hot');
logger.cacheEvent('stale', orgId, '2026-04-12');
logger.apiFetch(orgId, '/teetimes', 200, 142);
logger.rateLimited(tenantId, 5000);
Metrics & alerts
| Metric | Window |
|---|---|
| Cache hit rate | 1 minute rolling |
| API calls/min (total & per-tenant) | 1 minute rolling |
| Booking success rate & P50/P95 latency | Rolling 200 samples |
| Lock contention | 1 minute rolling |
| 429 responses | 5 minute rolling |
Alerts fire when API pressure exceeds 170/min, 429s exceed 3 in 5 minutes, cache staleness exceeds 2× poll interval, lock timeouts exceed 5/min, or booking success rate drops below 80%.
Security
Security surfaces are different for different users. Guests should face zero friction: no login required. Admins need scoped access that is easy to migrate later. The kiosk needs protection against brute-force lookups without adding authentication overhead to a shared device.
Session encryption
Guest booking state encrypted with AES-GCM (Web Crypto API). Key in sessionStorage (cleared on tab close), ciphertext in localStorage (survives page refresh). 30-minute inactivity expiry. This split means a guest can resume a booking after a page refresh but starts fresh in a new browser session, matching how people actually use the web.
Authentication
| Surface | Method |
|---|---|
| Guest booking | No authentication required (guest checkout) |
| Guest manage | Email + booking reference (rate-limited) |
| Customer account | JWT from configured identity provider; enforcement (optional / required) set per tenant |
| Admin API | API key → admin scope (super or tenant) |
| Admin portal | API key or OAuth via Supabase |
| Kiosk check-in | Email or reference lookup (rate-limited) |
Headers & CORS
Content Security Policy, X-Content-Type-Options, X-Frame-Options, Strict-Transport-Security, CORS with configurable origins.
Frontend
The frontend is vanilla HTML/JS served directly by the middleware, with no framework, no build step. Each guest-facing page is themed at load time from the tenant’s configuration, applied via CSS custom properties and dynamic font/logo injection. The booking page, manage page, and check-in kiosk all share the same theme system.
Guest-facing pages
| Page | URL | Purpose |
|---|---|---|
| Booking | /:slug or custom domain / | 6-step booking flow with SSE, extras, payments |
| Manage | /:slug/manage | View, update notes, cancel (email + reference auth) |
| Check-in | /:slug/check-in | Kiosk check-in with 8-second auto-reset |
| Account | /:slug/account | Customer account: booking history, profile (identity provider auth) |
Theme system
| Domain | Properties |
|---|---|
| Brand | Property name, logo URL, hero image (preloaded with fade-in) |
| Colours | Primary, hover, accent, highlight, background (CSS custom properties) |
| Fonts | Display, body, Google Fonts CSS URL |
| Styles | Border radii (card, button, input), shadow depths |
| Courses | Per-course short label, name, holes, display order |
| Labels | Button text, step names, header subtitle, currency symbol |
| Pricing | Default mode (per_person / total), toggle visibility |
| Features | Waiting list enabled, pause mode |
Admin portal
Single-page admin application with sectioned navigation: Operations (tee sheet, customers, bookings, waiting list, notices, storefront), Configuration (setup, system, brand, courses), Integrations (payments, webhooks, email, vouchers), and Super Admin (tenants, keys, accounts, branding).
Testing & Quality
The booking orchestrator, compensation logic, and caching layer all have real consequences when they fail: double-bookings, orphaned reservations, stale availability. The test strategy prioritises integration tests that exercise realistic scenarios end-to-end over unit tests of isolated functions.
Test strategy
| Layer | Approach | Examples |
|---|---|---|
| Booking | Integration with mocked Lightspeed + fake Redis | Happy path, double-booking guard, failure + rollback, timeouts |
| Multi-system | End-to-end with mock adapters | Multi-adapter execution, partial failure compensation |
| Payments | Integration with mock Stripe | Voucher issuance, refund settlement, webhook handling |
| Caching | Tier 2/3 integration tests | Cache freshness, polling budget, adaptive TTL |
| Security | Hardening tests | CSP headers, CORS, SSRF validation |
110 test files using Jest + Supertest. The booking integration tests alone cover: happy-path booking, double-booking prevention via optimistic lock, partial failure with compensation rollback, forward-path timeout expiry, idempotent retry, and multi-system adapter orchestration.
The fake Redis implementation is an in-memory store with NX-aware set, pipeline support, and pub/sub simulation. It is not a Redis client mock, but a behavioural replica that catches real concurrency bugs. The mocked Lightspeed API client returns realistic response shapes so integration tests exercise the actual adapter code paths. Admin routes are tested with injected adminScope to verify both super and tenant-level access.
Deployment
The local development environment mirrors production topology: six services with health checks and dependency ordering. A Lightspeed-compatible mock API (port 3001) replicates the upstream system’s response shapes and behaviour, so the full booking flow can be tested without a live Lightspeed account. Package and commerce functionality runs inside the middleware, not as a separate service.
Docker Compose stack
| Service | Image | Port |
|---|---|---|
| Redis | redis:7-alpine | 6379 |
| PostgreSQL | postgres:16-alpine | 5432 |
| Mock API | Custom (Lightspeed v2 shape) | 3001 |
| Middleware | Node 20 Alpine (multi-stage) | 3002 |
| Booking Reminder Service | Node 20 Alpine | 3005 |
| Email Service | Node 20 Alpine | 3004 |
Multi-stage Docker builds: builder compiles TypeScript; production image runs npm ci --omit=dev on Node 20 Alpine. All services use health checks and dependency ordering. 150+ environment variables covering credentials, polling parameters, feature flags, and service URLs.