GCL v2 — Architecture Proposal

A complete rebuild optimised for speed, scale, and rapid feature development

Recommended Stack

Based on your existing Go + Next.js experience in incremental, and research into what works best for a mobile-first PWA used outdoors on a golf course:

Alternative

Go + Next.js

Keeps your entire stack unified with incremental. Larger ecosystem, more hiring options. But carries 3-7x more JS to the client — not ideal for on-course use with spotty connectivity.

Infrastructure

Database
PostgreSQL + sqlc + goose migrations (same as incremental)
Cache / Sessions
Redis for auth sessions, entitlement caching, rate limiting
Hosting
Docker on a VPS at gcl.ecaddy.co.za — not cPanel shared hosting
CI/CD
Tag-based webhook deploy (same as incremental): make release → git tag → GitHub webhook → deploy.sh on VPS
Payments
Paystack (keep existing, add subscription billing)
Images / Assets
S3-compatible object storage (Cloudflare R2 or DigitalOcean Spaces) + CDN

System Architecture

                    ┌─────────────────────────────────────────┐
                    │           gcl.ecaddy.co.za              │
                    │              (Docker)                   │
                    └────────────────┬────────────────────────┘
                                     │
                ┌────────────────────┼────────────────────┐
                │                    │                    │
          ┌─────▼─────┐      ┌──────▼──────┐     ┌──────▼──────┐
          │ SvelteKit  │      │   Go API    │     │   Admin     │
          │   Player   │      │   Server    │     │   Panel     │
          │  (SSR/PWA) │      │  (Chi+sqlc) │     │ (SvelteKit) │
          └─────┬──────┘      └──────┬──────┘     └──────┬──────┘
                │                    │                    │
                │         ┌──────────┼──────────┐        │
                │         │          │          │        │
                │    ┌────▼───┐ ┌────▼───┐ ┌───▼────┐   │
                │    │Postgres│ │ Redis  │ │Paystack│   │
                │    │  (DB)  │ │(Cache) │ │  (Pay) │   │
                │    └────────┘ └────────┘ └────────┘   │
                │                                        │
                └──────────┐  ┌─────────────────────────┘
                           │  │
                      ┌────▼──▼────┐
                      │ R2 / CDN   │
                      │  (Images)  │
                      └────────────┘

Why This Architecture

  • Speed: SvelteKit SSR for instant first paint, then PWA caches everything for offline use. Go API is sub-millisecond. CDN serves images.
  • Scale: PostgreSQL handles millions of rounds. Redis caches hot entitlements. CDN absorbs image bandwidth. Go handles 10K+ concurrent connections.
  • Features: Go's Chi router + sqlc makes adding API endpoints fast. SvelteKit's component model makes UI iteration fast. Same patterns you use in incremental.
  • Hardening: Docker isolation, proper migrations, sops+age encrypted secrets, Redis-backed sessions, HMAC webhook verification.

Deploy Flow (Webhook-Based)

Same pattern as incremental — no GitHub Actions

  Developer: make release
       ↓
  scripts/release.sh
    → pnpm changeset version (bump + changelog)
    → git commit + git tag @gcl/api@1.2.0
    → git push origin main --tags
       ↓
  GitHub sends webhook to VPS (gcl.ecaddy.co.za)
    → HMAC-SHA256 signature verified
    → Tag pattern matched: refs/tags/@gcl/*deploy.sh executes:
    1. Acquire deploy lock (prevent concurrent deploys)
    2. git pull --ff-only
    3. sops decrypt secrets → .env files
    4. docker compose build (tagged with version)
    5. pg_dump backup → /opt/backups/gcl/
    6. goose migrations (120s timeout)
    7. docker compose up -d --force-recreate
    8. Health check: curl /health (15 retries)
    9. On failure: rollback to previous image
   10. Notify via webhook on success/failure
Caddy
Reverse proxy + auto SSL. Routes gcl.ecaddy.co.za to player/api/admin containers.
sops + age
Encrypted secrets in git. Decrypted on VPS at deploy time. No plaintext credentials in repo.
Webhook listener
Same webhook tool as incremental. Config template + HMAC secret.
Rollback
Tag current images as :rollback before deploy. Restore on health check failure.

GCL v2 — Business & Monetization Model

Flexible monetization that works for clubs, golfers, and growth

The Problem with Current Monetization

Today: golfer scans QR → pays R30-50 per round → gets 8-12 hours access → expires. Three limitations:

  • No recurring revenue — every customer must be re-acquired each round
  • No data persistence — preferences reset if device ID changes
  • Single channel — only golfer-pays. Clubs can't subsidise access

Proposed: Hybrid Model

Three revenue channels, configurable per course:

Channel 1
Per-Round Rental

Keep what works. Golfer scans QR, pays once, gets access for the round.

Improvement: If they have an account, the rental is linked to their profile (not just device).

R30-50 / round

Channel 2
Round Bundles

Buy rounds upfront, use them anytime. No recurring charge anxiety — pay once, play at your pace.

Bundles:

  • 5 rounds: ~R150 (R30/round)
  • 10 rounds: ~R250 (R25/round)
  • 30-day pass: ~R200 (unlimited during window)

Upfront revenue, better for casual golfers

Channel 3
Club-Pays (B2B SaaS)

Club pays monthly fee. All golfers get free access. White-label branding.

Includes: Course setup, analytics, pace management, slow play reporting.

R1,500-3,000/mo per course

Access Flow

  Golfer scans QR at course
         │
         ▼
  ┌─────────────────┐
  │  Course Player   │──── Is course free (club-pays)?
  │  loads in PWA    │          │
  └────────┬────────┘    YES ──┼──▶ Full access immediately
           │              NO ──┘
           ▼
  ┌─────────────────┐
  │ Check entitlement│──── Has remaining bundle credits or active pass?
  │  (device + user) │          │
  └────────┬────────┘    YES ──┼──▶ Full access (deduct 1 credit)
           │              NO ──┘
           ▼
  ┌─────────────────┐
  │  Locked screen   │     ┌──────────────────────────┐
  │                  │────▶│ "Play this round — R35"   │ → Single charge
  │                  │     │ "5 rounds — R150 (save)"  │ → Bundle purchase
  │                  │     │ "10 rounds — R250 (best)" │ → Bundle purchase
  └──────────────────┘     └──────────────────────────┘
           │
           ▼
  ┌─────────────────┐      Saved card? → One-tap purchase
  │ First time:      │      (Paystack charge_authorization)
  │ enter card via   │
  │ Paystack modal   │──── Card saved automatically for
  └────────┬────────┘      future one-tap purchases
           ▼
  ┌─────────────────┐
  │ Optional: create │──── "Save your preferences?"
  │ account          │     Email + optional password
  └─────────────────┘     (or skip — device-based like today)

User Accounts — Optional but Incentivised

Without Account (Guest)
  • Per-round rental only
  • Device-based entitlement
  • Settings saved in localStorage
  • Lost if cache cleared or new phone
With Account
  • Buy bundles (5/10 rounds) at a discount
  • Saved card — one-tap future purchases
  • Preferences sync across devices
  • Round history and remaining credits visible
  • Works on new phone automatically

Paystack: Saved Cards + One-Tap Purchases

FIRST PURCHASE (card entry required)

  Backend: POST /transaction/initialize
    → { email, amount, metadata: { bundle: "10_rounds" } }
    → Returns access_code

  Frontend: PaystackPop.resumeTransaction(access_code)
    → User enters card in Paystack modal
    → Paystack processes payment + 3DS auth

  Backend: GET /transaction/verify/{reference}
    → Response includes authorization object:
      {
        "authorization_code": "AUTH_xxx",  ← save this
        "reusable": true,                  ← must be true
        "last4": "4081",
        "card_type": "visa"
      }
    → Store auth code against user in DB
    → Credit 10 rounds to user's balance


FUTURE PURCHASES (one-tap, no card entry)

  Backend: POST /transaction/charge_authorization
    → {
        "authorization_code": "AUTH_xxx",
        "email": "user@example.com",
        "amount": 25000,
        "metadata": { "bundle": "10_rounds" }
      }
    → Charges saved card silently
    → Credit 10 more rounds to balance

  No user interaction needed. One tap on the frontend triggers the server-side charge.

Users can manage their saved cards — list authorizations and deactivate old ones via POST /customer/authorization/deactivate. The signature field on each authorization prevents duplicate card storage.

GCL v2 — Player UX Redesign

Designed for one-handed use on the course, readable in sunlight, fast on weak signal

Current Problems (from code analysis)

Modal chains — Changing bag + tee + wind = 12+ taps through nested modals that close the parent hub each time
Battery drain — Canvas renders at 60fps continuously, even when idle. Solver runs on every mousemove with no throttle
Dark theme outdoors — Current dark UI is hard to read in direct sunlight. Research shows light mode with AAA contrast (7:1) is better
Top-heavy navigation — Settings/pace buttons in top-right corner, outside thumb zone
GPS is hidden — Small golf ball FAB in bottom-left, easy to miss. No visual feedback of accuracy
No quick-glance info — Must interact to see distances. Leading apps show front/center/back at a glance

Redesigned Player Layout

Mobile-first, thumb-zone optimised, sunlight-readable

Current (v1)
King David Mowbray
7
White 400m | Red 350m
Par 4 | Stroke 5
Pro Tip
Aim right of the fairway bunker...
Proposed (v2)
7
Par 4 · SI 5
370m (White)
Wind
12 km/h SW
Front
142
Pin
155
Back
168
☉ GPS
7 Iron
Plays like
162m (+7m wind)
Prev
Settings
Map
Pace
Next

Key UX Improvements

1. Light Theme for Outdoors
Dark mode is fuzzy in bright light. Switch to high-contrast light (#F5F5F0) with 7:1+ ratios. Keep dark as evening option.
2. Bottom Navigation Bar
Move all nav into the thumb zone. Currently settings are top-right (hardest to reach).
3. Always-Visible Distances
Front/Pin/Back shown at all times. Every leading golf app does this. Pin distance large enough to read from arm's length.
4. "Plays Like" Distance
"155m plays like 162m (+7m headwind)". 18Birdies charges $100/yr for this. GCL already has the physics — just surface it.
5. Inline Settings Panel
Replace 5-item hub → sub-modal with one slide-up panel. All settings visible at once. One panel, one close.
6. Prominent GPS
Larger button with accuracy indicator. Pulsing while locating. Auto-update while walking (adaptive: 5s moving, 30s idle).
7. Swipe Navigation
Swipe left/right between holes. Haptic feedback. Auto-advance when GPS detects next tee box.
8. Aggressive Offline
Pre-cache entire course on first load. Cache weather 10min. Stale-while-revalidate. Never break the experience.

Performance Targets

<1s
First paint (cached)
<10KB
Initial JS (gzipped)
0
Layout shifts
30fps
Canvas (idle: 0fps)
100%
Offline playable

GCL v2 — Admin Panel & Data Model

Scalable course management, proper versioning, analytics

Current Admin Pain Points

85 versions for 6 courses — Publishing just swaps a pointer. No rollback. Version numbers never increment.
21+ manual uploads per course — Logo + overview + mini map + 18 hole images. No drag-drop, no bulk, no validation.
No course templates — Every course from scratch. Can't clone or duplicate.
No analytics — Can't see popularity, rentals/day, completion rates.
Single admin user — No roles, no audit trail. Club managers can't self-serve.
No QA workflow — Can't preview as a golfer. No validation report.

New Admin Features

📊 Dashboard with Analytics

  • Active rentals, subscriptions, revenue (today/week/month)
  • Course popularity ranking
  • Per-course health: completeness score
  • Recent activity feed

Improved Course Editor

  • Drag-and-drop image upload — drop all 18 at once
  • Interactive overview map — click to place hole markers
  • Course cloning — duplicate as starting point
  • Bulk tee editor — spreadsheet-style grid
  • Live preview — see it as a golfer
  • Completeness checklist

📍 Visual Hole Calibration Tool

  • Canvas overlay — click to place tee, target, green, GPS anchors
  • Satellite map overlay — side-by-side for GPS coord capture
  • Distance validation — compare calculated vs tee yardages
  • Sim preview — test simulation right in the editor

👥 Multi-Tenant Access

  • Roles: Super Admin, Club Admin (own courses only), Viewer
  • Club Admin portal: self-serve course management + analytics
  • Audit log: who changed what, when
  • Subscription management: view/cancel/extend

PostgreSQL Data Model

-- Core entities
clubs            (id, name, slug, logo_url, created_at)
courses          (id, club_id FK, name, slug, location, currency, price_cents,
                  rental_duration_min, is_free, visible, created_at, updated_at)

-- Versioning (proper this time)
course_versions  (id, course_id FK, version_number SERIAL, state ENUM(draft/published/archived),
                  overview_image_url, mini_map_url, manifest_hash, published_at, created_by FK)

-- Hole data (per version)
holes            (id, course_version_id FK, hole_number, title, par, stroke_index,
                  pace_minutes, map_image_url, overview_x, overview_y)
hole_tees        (id, hole_id FK, tee_key, label, color, yardage_m, sort_order)
hole_sim         (id, hole_id FK, legs, canvas_w, canvas_h, px_per_meter,
                  hole_rotation_deg, points JSONB, tees JSONB, geo_anchors JSONB)
hole_notes       (id, hole_id FK, title, body)

-- Users & Auth
users            (id, email, password_hash, name, created_at)
user_profiles    (user_id FK, bag JSONB, preferred_tee, preferred_units,
                  ground_firmness, shot_shape, wind_prefs JSONB)
admin_users      (id, user_id FK, role ENUM(super_admin/club_admin/viewer), club_id FK NULL)

-- Monetization
orders           (id, reference, user_id FK NULL, device_id, course_id FK,
                  amount_cents, currency, provider, provider_ref, status, paid_at)
entitlements     (id, user_id FK NULL, device_id, course_id FK,
                  source ENUM(rental/bundle/club/manual),
                  starts_at, ends_at, state, created_at)
user_credits     (id, user_id FK, credit_type ENUM(rounds/days), remaining INT,
                  expires_at NULLABLE, purchased_at, order_id FK)
payment_methods  (id, user_id FK, provider, authorization_code, last4, card_type,
                  brand, exp_month, exp_year, signature, is_default, created_at)

-- Analytics & Audit
round_events     (id, course_id FK, user_id FK NULL, device_id,
                  event_type, hole_number, metadata JSONB, created_at)
audit_log        (id, admin_user_id FK, action, entity_type, entity_id, diff JSONB, created_at)

-- Indexes
CREATE INDEX idx_entitlements_lookup ON entitlements (course_id, device_id, state, ends_at);
CREATE INDEX idx_entitlements_user ON entitlements (user_id, course_id, state, ends_at);
CREATE INDEX idx_orders_reference ON orders (reference);
CREATE INDEX idx_courses_slug ON courses (slug);

Versioning — Done Right

Current (Broken)
  • Publish = swap pointer
  • No new version row created
  • version_number never incremented
  • No rollback possible
  • 85 orphaned version rows
v2 (Proper)
  • Publish = deep clone draft → new published version
  • version_number auto-incremented
  • manifest_hash computed from content
  • Rollback = re-point to previous version
  • Audit log: who published, when, what changed

Go API Endpoints

// Public (no auth)
GET  /health
GET  /api/v1/courses/:slug              → Course overview (public listing)
GET  /api/v1/courses/:slug/play         → Full course JSON (entitlement-gated)
POST /api/v1/courses/:slug/entitlement  → Check access (device_id + user_id)

// Payments
POST /api/v1/orders                     → Create order (rental or bundle, Paystack init)
POST /api/v1/orders/charge              → Charge saved card (one-tap repurchase)
GET  /api/v1/orders/:ref/status         → Poll payment status
POST /api/v1/webhooks/paystack          → Webhook receiver

// Auth
POST /api/v1/auth/register              → Create account
POST /api/v1/auth/login                 → Login → session token
GET  /api/v1/auth/me                    → Current user + profile

// User (session auth)
GET  /api/v1/profile                    → Bag, prefs, history, credits balance
PUT  /api/v1/profile                    → Update preferences
GET  /api/v1/profile/cards              → List saved payment methods
DEL  /api/v1/profile/cards/:id          → Remove saved card
GET  /api/v1/profile/credits            → Remaining rounds/days per course

// Admin (session auth + admin role)
GET  /api/v1/admin/courses              → All courses
POST /api/v1/admin/courses              → Create course
PUT  /api/v1/admin/courses/:id          → Update course
POST /api/v1/admin/courses/:id/publish  → Publish draft
POST /api/v1/admin/courses/:id/clone    → Clone course
CRUD /api/v1/admin/courses/:id/holes/*  → Hole management
POST /api/v1/admin/upload               → Image upload → S3
GET  /api/v1/admin/analytics            → Dashboard stats

GCL v2 — Repo Structure & Migration Path

How the codebase is organised and how we get from v1 to v2

Repository Structure

gcl/
├── go/                              # Go backend
│   ├── cmd/gcl/main.go               # Entry point
│   ├── internal/
│   │   ├── config/                    # Env-based configuration
│   │   ├── domain/
│   │   │   ├── course/                # Course CRUD, versioning
│   │   │   ├── payment/               # Paystack orders, subscriptions
│   │   │   ├── entitlement/           # Access control
│   │   │   ├── identity/              # Auth, sessions, profiles
│   │   │   └── analytics/             # Event tracking
│   │   ├── repository/                # sqlc-generated
│   │   └── transport/http/            # Chi router, handlers
│   ├── pkg/
│   │   ├── auth/                      # JWT, sessions
│   │   ├── paystack/                  # Paystack client
│   │   ├── storage/                   # S3/R2 upload
│   │   └── weather/                   # Open-Meteo
│   ├── migrations/                    # Goose SQL
│   ├── queries/                       # sqlc queries
│   └── Dockerfile
│
├── apps/
│   ├── player/                       # SvelteKit PWA
│   │   └── src/
│   │       ├── routes/               # [slug]/[hole] routing
│   │       ├── lib/
│   │       │   ├── sim/              # Physics engine (ported)
│   │       │   ├── gps/              # GPS stabilisation
│   │       │   ├── stores/           # Svelte stores
│   │       │   └── api.ts            # API client
│   │       ├── components/           # HoleMap, DistanceBar, etc
│   │       └── service-worker.ts     # Offline caching
│   │
│   └── admin/                        # SvelteKit admin
│       └── src/
│           ├── routes/               # Dashboard, courses, holes
│           └── lib/                  # Admin components + API
│
├── infra/
│   ├── Caddyfile                     # Reverse proxy config
│   ├── webhook.conf.tmpl             # Webhook listener config
│   └── secrets/                      # sops-encrypted .enc.env files
├── scripts/
│   ├── deploy.sh                     # VPS deploy (triggered by webhook)
│   └── release.sh                    # Local: changeset → tag → push
├── docker-compose.yml
├── docker-compose.prod.yml
├── Makefile
└── .env.example

Migration Path: v1 → v2

No backwards compatibility needed — clean cut with data migration

1
Set up infrastructure
VPS at gcl.ecaddy.co.za. Caddy (reverse proxy + auto SSL). Webhook listener. Docker Compose with PostgreSQL + Redis. sops+age for secrets. GitHub repo.
2
Build Go API core
Chi router, sqlc queries, goose migrations, health checks. Course CRUD, hole CRUD, entitlement checking. Auth with sessions. Paystack integration (rentals + subscriptions).
3
Port the simulation engine
Extract ~2500 lines of physics code into TypeScript modules: engine.ts, solver.ts, clubs.ts, renderer.ts. This is the core IP — needs careful porting with test coverage.
4
Build SvelteKit player
New UI with light theme, bottom nav, quick-glance distances, inline settings, improved GPS. Wire up ported sim engine. Service worker for offline.
5
Build admin panel
SvelteKit admin with dashboard, course editor, hole calibration tool, image upload, analytics. Multi-tenant auth with club admin roles.
6
Migrate data
Go migration script reads the MySQL dump and imports: 6 clubs, 6 courses, live version data, all holes with tees + sim + notes. Upload images to S3/R2.
7
Cut over
Point gcl.ecaddy.co.za DNS to new VPS. Set up redirects from old paths. Old PHP app stays on cPanel during transition.

Docker Compose (Local Dev)

services:
  db:
    image: postgres:16-alpine
    environment:
      POSTGRES_DB: gcl
      POSTGRES_USER: gcl
      POSTGRES_PASSWORD: dev
    ports: ["5432:5432"]

  redis:
    image: redis:7-alpine
    ports: ["6379:6379"]

  api:
    build: ./go
    depends_on: [db, redis]
    environment:
      DATABASE_URL: postgres://gcl:dev@db/gcl?sslmode=disable
      REDIS_URL: redis://redis:6379
      PAYSTACK_SECRET_KEY: ${PAYSTACK_SECRET_KEY}
    ports: ["8080:8080"]

  player:
    build: ./apps/player
    depends_on: [api]
    ports: ["3000:3000"]

  admin:
    build: ./apps/admin
    depends_on: [api]
    ports: ["3001:3001"]

Summary

Stack: Go (Chi + sqlc + goose) + SvelteKit (player PWA + admin) + PostgreSQL + Redis + S3/CDN

Hosting: Docker on VPS at gcl.ecaddy.co.za. Caddy reverse proxy. Tag-based webhook deploy.

Monetization: Per-round rentals + round bundles (5/10 packs) + day passes + club-pays B2B. Saved cards for one-tap repurchase.

Player UX: Light theme, bottom nav, quick-glance distances, inline settings, GPS, offline. <10KB JS.

Admin: Analytics, drag-drop images, course cloning, visual calibration, multi-tenant roles, audit log.

Migration: 7-step path. Data migration from MySQL dump. No backwards compat needed.