← GolfKit

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.

1MiddlewareExpress API: caching, booking, package catalog, admin, webhooks (port 3002)
2Booking Reminder ServicePre-tee-time notification scheduler; consumes booking webhooks and fires reminders at configured lead times (port 3005)
3Email ServiceTenant-branded notifications via webhook triggers (port 3004)
4Stripe GatewayIsolated payment webhook ingress with HMAC verification (port 8787)
5Redis 7Cache, pub/sub, coordination, write-ahead log (port 6379)
6PostgreSQL 16Durable audit trail, configuration, analytics (port 5432)

Tech Stack

Core framework & runtime

TechnologyPurpose
Node.js 20Runtime (Alpine-based production image)
ExpressHTTP framework for middleware and companion services
TypeScript (strict)Type safety across all services

Data & state

TechnologyPurpose
Redis 7 (ioredis)Cache, pub/sub, booking locks, demand counters, write-ahead log
PostgreSQL 16Durable audit trail, configuration, event store
Knex.jsSQL query builder and migration runner

Integrations

TechnologyPurpose
Lightspeed Golf API v2Upstream system of record for tee times, reservations, and payments
StripePayment processing with per-tenant credentials
NodemailerTransactional email delivery (email-service)
RenderCustom domain provisioning with automatic TLS
SupabaseOAuth backend for admin portal (optional)

Quality & monitoring

TechnologyPurpose
Jest + SupertestIntegration and unit testing (110 test files)
SentryError tracking across all services
Docker ComposeLocal 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

EndpointPurpose
GET /teetimes/:orgId/:dateSWR tee times with X-Cache headers (hit/miss/stale)
GET /availability/:orgIdDate-range availability summary (multi-date batch)
GET /changes/:orgId/:dateSSE stream of real-time tee-time changes
GET /itemsMode-aware product catalog (passthrough or customised)
POST /bookMulti-system booking (Lightspeed + adapters)
DELETE /book/:reservationIdCancellation with compensation and refund settlement
POST /manageGuest self-service lookup (email + reference)
POST /waiting-list/:orgId/joinGuest joins day-based waiting list
POST /storefront/:orgId/checkin/lookupKiosk check-in lookup (email or reference)
POST /storefront/:orgId/checkinRecord check-in (person + reservation level)
GET /customer-account/bookings/:orgIdAuthenticated customer: booking history for the venue
POST /customer-account/link-bookingLink an existing booking to a customer account
GET /booking/payment-configPayment configuration for the checkout flow

Admin operations

EndpointPurpose
/admin/bookings/:orgId/logBooking audit trail with filtering and pagination
/admin/cancellations/:orgId/logCancellation history with refund details
/admin/notices/:orgId/:dateNotice CRUD, day/time blocking, bulk cancellation
/admin/waiting-list/:orgIdWaiting list inspection, config, conversion tracking
/admin/catalog/:orgIdProduct curation: items, categories, sync, course mappings
/admin/webhooksDestination CRUD, delivery log, secret rotation
/admin/tenants/:tenantId/credentialsEncrypted credential management
/admin/theme/:tenantIdPer-venue branding configuration
/admin/storefront/:orgId/slugCustom slug management
/admin/storefront/:orgId/hostnameCustom domain + TLS provisioning
/admin/payment-settings/:orgIdStripe keys, refund mode, tier policy
/admin/feature-governance/:orgIdFeature toggle management with usage tracking
/admin/pricing/:orgIdPrice override rules (absolute, percentage, or fixed; date/time/player-type scoped)
/admin/reminders/:orgIdBooking reminder lead-time configuration (proxied to reminder service)
/admin/revenue/:orgIdRevenue dashboard: bookings, gross, refunds, net for a date range
/admin/reports/:orgIdSaved revenue report definitions and results
/admin/reconciliation/:orgIdReconciliation between platform records and upstream system
/admin/teesheet/:orgIdAdmin tee-sheet view with operational overlays
/admin/customers/:orgIdCustomer contact management and booking history lookup
/admin/courses/:orgIdPer-org course display configuration
/admin/pause/:orgIdPause mode toggle (replaces booking page with custom content)
/admin/auth/meCurrent admin scope introspection

System

EndpointPurpose
GET /healthService health check (Redis, Postgres, API)
GET /metricsIn-process metrics snapshot
POST /invalidateManual 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 patternTypePurpose
teetimes:{orgId}:{date}HashTee-time data (JSON per slot)
avail:{orgId}:{date}StringAvailability summary sidecar
poll_meta:{orgId}:{date}HashLast polled timestamp, page count, interval (7-day TTL)
teetime_status:{teetimeId}StringBooking lock (SET NX, 120s TTL)
demand:{orgId}:{date}CounterRequest count in 10-minute sliding window
demand_dedup:{orgId}:{date}:{ip}StringPer-IP dedup (10s TTL)

Date tiering (8 tiers)

TierDay offsetBehaviour
0TodayShortest TTL, most frequent polling
1TomorrowNear-real-time freshness
22–7 daysActive booking window
38–14 daysNear-term lookahead
415–30 daysMedium-term
531–60 daysBackground freshness
661–90 daysInfrequent polling
790+ daysOn-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)

PropertyValue
Max connections100 (global cap, 503 when exceeded)
Heartbeat30 seconds (: ping comment)
Client retry3,000ms
MultiplexerShared Redis subscription per channel (not per client)
Reconnectionconnected 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)

1Idempotency checkRedis state machine prevents duplicate bookings
2Notice guardBlocks unavailable dates and blocked time slots
3Package resolutionConverts package IDs to Lightspeed product IDs if customised
4Amount validationConfirms sum(items[*].qty × unitPrice) === total
5Optimistic lockRedis SET NX on tee-time slot (120s TTL)
6Pre-booking validationLive fetch: free slots ≥ player count, price unchanged
7Adapter executionLightspeed first (4-step flow), then remaining adapters concurrently

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

StateTTLAction on duplicate request
pending120 secondsReturn 409 Conflict
committed24 hoursReturn cached success result
failed24 hoursReturn 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 actionEffect
Review inboxNew products arrive as hidden, counter resets on read
Publish / unpublish / disableControls visibility to guests
Set display name & descriptionOverrides Lightspeed name in guest UI
Assign categoryPlaces item in featured / extras / occasions slot
Set featured + rankPins item to top of section with explicit ordering
Acknowledge removalClears warning when Lightspeed deletes a product
Trigger syncImmediate 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

ConditionBooking orchestrator behaviour
unavailable: trueAll bookings rejected with date_unavailable
Tee time in blocked rangeBooking rejected with timeslot_blocked
requiresConfirmation: trueBooking 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

MethodInputResponse
EmailCustomer emailAll bookings in check-in window for that email
ReferenceBooking referenceSingle booking with window status

Check-in levels

LevelSystemTrigger
ReservationLightspeed APIFirst person to check in triggers group check-in upstream
PersonPlatform checkins tableEach 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)

ActionLimit
IP lookup60 / minute
Email lookup5 / minute
IP check-in30 / minute
Email check-in3 / 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

PropertyDetail
Format2–50 chars, lowercase alphanumeric + hyphens
Reservedadmin, api, health, metrics, static, index
LookupRedis index tenant_by_slug:{slug} → orgId
Routes/:slug (booking), /:slug/admin, /:slug/check-in, /:slug/manage

Custom domain routing

PropertyDetail
FormatStandard DNS hostname (must contain at least one dot)
Reservedlocalhost, golfkit.io, golfkit.com, golfkit.net
LookupRedis index tenant_by_hostname:{hostname} → orgId
TLSAutomatic provisioning via Render API (renderDomainId stored per tenant)
WildcardGolfKit-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

TablePurposeKey columns
booking_logEvery booking attemptorg_id, teetime_id, result, orders (JSONB), duration_ms
booking_factsDenormalised booking data for analytics & guest self-servicetenant_id, reservation_id, customer_email_norm, teetime_date
cancellation_logCancellation audit trailreservation_id, trigger, refund_id, voucher_issuance_id
eventsUnified append-only event storetenant_id, event_type, entity_id, event_payload (JSONB)
checkinsPer-person check-in recordstenant_id, reservation_id, email, source, checked_in_at

Payment tables

TablePurpose
paymentsStripe payment intents (status, amount, metadata)
refundsRefund records linked to payment intents and cancellation log
voucher_issuancesVoucher correlation records (org, reference, face value)
org_payment_settingsPer-org refund mode, tier policy, voucher toggle
org_stripe_credentialsEncrypted Stripe keys per org (AES-256-GCM)

Operational tables

TablePurpose
daily_noticesClosures, warnings, blocked ranges (soft-delete)
notice_confirmationsGuest acknowledgement audit trail
waiting_listDay-based entries with status and conversion tracking
waiting_list_configsPer-tenant feature toggle
customer_contactsEmail capture from bookings and check-ins

Catalog tables

TablePurpose
storefront_itemsCurated product catalog (synced from Lightspeed, admin-editable)
storefront_categoriesDisplay categories (featured / extras / occasions)
catalog_sync_statePer-org sync metadata (last synced, product count, inbox counter)
product_course_mappingsProduct-to-course restrictions

Customer account tables

TablePurpose
customer_accountsFederated identity accounts (provider + subject); one record per identity
customer_account_linksLinks an account to a per-tenant customer record; revocable, one active link per tenant per customer
tenant_auth_settingsPer-tenant customer auth configuration (provider, enforcement mode, JIT linking toggle)

Pricing & analytics tables

TablePurpose
golfkit_price_overridesPer-org price rules: absolute, percentage, or fixed adjustment scoped by date range, time window, and/or player type
revenue_journalDouble-entry revenue ledger; source of truth for revenue reporting and reconciliation

Tenant & configuration tables

TablePurpose
cg_venuesPre-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_signupsTenant registration (slug, hostname, status, plan)
tenant_themesPer-venue branding (JSONB overrides)
tenant_admin_keysScoped admin API keys (hashed, expirable, revocable)
credential_audit_logCredential access trail per tenant
feature_governanceFeature toggles per tenant with locking
feature_usage_monthly_mvMaterialised view: monthly usage aggregation for billing
course_configs / course_overridesPer-tenant course display configuration
user_accountsAdmin user accounts with role and org binding
platform_settingsSystem-wide configuration (key-value)

Webhook & email tables

TablePurpose
webhook_configsDestination definitions (URL, secret, enabled events)
webhook_delivery_logEvery delivery attempt (status, latency, response body)
webhook_audit_logConfiguration change audit
email_connectorsPer-org email provider config (encrypted credentials, status)
email_event_routesPer-org routing: which events trigger emails, to whom
email_templatesEditable templates per org per event type
email_send_historyDelivery audit trail (status, provider, message ID)
email_tenant_configsTenant-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

PropertyValue
AlgorithmAES-256-GCM
IV12 bytes (random per write)
Key derivationHKDF(SHA-256, rootKey, salt = tenantId)
DEK size32 bytes
Version tracking8-char fingerprint of root key per ciphertext
RotationSet 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

ComponentConfiguration
Queuewebhook_dispatch_queue (Redis list, LPOP/RPUSH)
Dead-letterwebhook_dispatch_dead
Worker poll300ms interval, 25 events/tick, 10 concurrent dispatches
RetryUp 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)

1Hostname blocklistlocalhost, private ranges, IPv6 link-local, cloud metadata, .local/.internal
2Port blocklist21, 22, 23, 25, 110, 143, 389, 3306, 5432, 6379, 9200, 11211, 27017
3DNS rebinding checkResolves hostname at dispatch time, re-validates resolved IP

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)

ConditionResponse
429 rate limitWait for retry-after, resume at 80% for 2 minutes, then 100%
5xx errorExponential backoff: min(1000 × 2^(errors-1), 30000)ms
5 consecutive 5xxCircuit opens for 60 seconds, auto-closes
Success after errorsError 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

MetricWindow
Cache hit rate1 minute rolling
API calls/min (total & per-tenant)1 minute rolling
Booking success rate & P50/P95 latencyRolling 200 samples
Lock contention1 minute rolling
429 responses5 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

SurfaceMethod
Guest bookingNo authentication required (guest checkout)
Guest manageEmail + booking reference (rate-limited)
Customer accountJWT from configured identity provider; enforcement (optional / required) set per tenant
Admin APIAPI key → admin scope (super or tenant)
Admin portalAPI key or OAuth via Supabase
Kiosk check-inEmail 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

PageURLPurpose
Booking/:slug or custom domain /6-step booking flow with SSE, extras, payments
Manage/:slug/manageView, update notes, cancel (email + reference auth)
Check-in/:slug/check-inKiosk check-in with 8-second auto-reset
Account/:slug/accountCustomer account: booking history, profile (identity provider auth)

Theme system

DomainProperties
BrandProperty name, logo URL, hero image (preloaded with fade-in)
ColoursPrimary, hover, accent, highlight, background (CSS custom properties)
FontsDisplay, body, Google Fonts CSS URL
StylesBorder radii (card, button, input), shadow depths
CoursesPer-course short label, name, holes, display order
LabelsButton text, step names, header subtitle, currency symbol
PricingDefault mode (per_person / total), toggle visibility
FeaturesWaiting 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

LayerApproachExamples
BookingIntegration with mocked Lightspeed + fake RedisHappy path, double-booking guard, failure + rollback, timeouts
Multi-systemEnd-to-end with mock adaptersMulti-adapter execution, partial failure compensation
PaymentsIntegration with mock StripeVoucher issuance, refund settlement, webhook handling
CachingTier 2/3 integration testsCache freshness, polling budget, adaptive TTL
SecurityHardening testsCSP 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

ServiceImagePort
Redisredis:7-alpine6379
PostgreSQLpostgres:16-alpine5432
Mock APICustom (Lightspeed v2 shape)3001
MiddlewareNode 20 Alpine (multi-stage)3002
Booking Reminder ServiceNode 20 Alpine3005
Email ServiceNode 20 Alpine3004

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.