This file is the authoritative record of what is implemented. Update it whenever features are added, changed, or removed. Claude reads this at the start of each session to avoid README drift.
Square (primary):
- Credit/debit card via Square hosted checkout
- Google Pay + Apple Pay via Square Web Payments SDK (
SquareWalletButtons.tsx) - Card on File (
cardsApi.createCard) for membership auto-renewal - Charges via
paymentsApi.createPayment - Credentials stored in DB (
square_access_token,square_application_id,square_location_id,square_environment) - Client reads config at runtime from DB — no
NEXT_PUBLIC_env vars needed; browser config served via/api/config/public
QuickBooks (invoice / ACH):
- OAuth flow, invoice creation
- Credentials in DB (
quickbooks_client_id,quickbooks_client_secret,quickbooks_redirect_uri,quickbooks_environment)
Stripe: NOT present. Was never implemented. Any README reference to Stripe is incorrect.
Square POS webhook (/api/webhooks/square): Directory stub exists, no implementation yet.
Toast POS webhook (/api/webhooks/toast/route.ts): Implemented.
- Verifies HMAC-SHA256 signature (
Toast-Signature: t=...,v1=...header) usingtoast_webhook_secretfrom DB - Handles
CHECK_CLOSEDevents only; all other event types return{ ok: true, skipped: true } - Validates
restaurantGuidagainsttoast_location_guidif configured - Matches tab name against active memberships (exact full-name match, then partial last-name match)
- Inserts into
member_chargeswithsource='toast',charge_type='bar_tab',external_id='toast-<check_guid>' - Deduplicates via UNIQUE constraint on
external_id— safe to retry webhook delivery - Unmatched tabs create standalone charges (no
membership_id) so no charges are lost - Configure in Admin → Settings → Toast POS; set webhook URL in Toast Partner Portal:
POST /api/webhooks/toast
- NextAuth v5 (Auth.js)
- Providers: Credentials (email/password, bcrypt), Google OAuth, Apple Sign In
- Google/Apple credentials read from DB at runtime (NOT env vars) — require server restart to take effect
- Edge-safe split:
auth.config.ts(no DB, used by middleware) +auth.ts(full Node.js config) - Middleware protects
/admin/*— checks JWT only - Admin layout does DB lookup (
isAdminEmail()) for actual role verification - Session shape:
{ user: { id, name, email, image, isAdmin } } - Default admin on fresh DB:
admin@swanlakecc.com/admin
- PostgreSQL via
pg(node-postgres) - Connection via
DATABASE_URLenvironment variable - Migrations:
npm run db:migrate(src/lib/db/migrate.ts) — idempotent, safe to re-run - Uses
CREATE TABLE IF NOT EXISTS,ON CONFLICT DO NOTHING,ADD COLUMN IF NOT EXISTS
| Table | Purpose |
|---|---|
users |
Registered accounts (id, email, name, password_hash) |
admin_users |
Emails with admin access (id, email, added_by, created_at) |
site_config |
Key/value store for all operational settings |
tee_times |
Booked tee time slots |
memberships |
Member records with payment and NFC info |
events |
Club events (public + private) |
event_registrations |
Event sign-ups |
tournaments |
Tournament definitions |
tournament_entries |
Individual player registrations |
tournament_teams |
Teams formed after draw |
equipment |
Physical inventory (carts, buggies, clubs) |
member_charges |
Bar tabs, cart storage, invoices, other charges — includes source (manual/toast) and external_id (unique dedup key for Toast webhooks) |
checkins |
Desk check-in log (NFC, member search, walk-in, tee time) |
All editable via Admin → Settings. No rebuild needed.
| Key | Default | Notes |
|---|---|---|
course_open |
true |
Enables/disables online booking |
booking_days_ahead |
7 |
How far ahead guests can book |
green_fee_9_holes |
25 |
Display only |
green_fee_18_holes |
35 |
Display only |
cart_fee_per_9 |
10 |
Per cart per 9 holes |
buggy_fee |
5 |
Per buggy per round |
clubs_fee |
15 |
Per set per round |
personal_cart_drop_fee |
15 |
Per day |
cart_storage_fee |
250 |
Per season, used in billing |
contact_phone |
(218) 885-3543 |
|
contact_email |
golf@swanlakecc.com |
|
season_start |
05-01 |
MM-DD format — enforced on bookings |
season_end |
10-31 |
MM-DD format — enforced on bookings |
tee_time_open |
07:00 |
Filters booking grid |
tee_time_close |
17:48 |
Overridden by sunset cutoff if earlier |
clubhouse_open |
06:00 |
Display only |
clubhouse_close |
20:00 |
Display only |
sunset_cutoff_enabled |
true |
Auto-close N hrs before sunset |
sunset_cutoff_hours |
2 |
Hours before sunset to stop bookings |
course_latitude |
47.72 |
Used for sunset calculation |
course_longitude |
-93.01 |
Used for sunset calculation |
smtp_host, smtp_port, smtp_secure, smtp_user, smtp_pass, smtp_from
square_access_token (secret), square_application_id, square_location_id, square_environment
quickbooks_client_id, quickbooks_client_secret (secret), quickbooks_redirect_uri, quickbooks_environment
google_client_id, google_client_secret (secret), apple_id, apple_secret (secret)
cron_secret — shared secret for POST /api/admin/billing/renew?secret=…
- 12-minute slots, 07:00–17:48 (55 total), filtered by
tee_time_open/tee_time_close - Party sizes 1–12; 5–8 players = 2 slots, 9–12 = 3 consecutive slots
- Concurrency:
BEGIN IMMEDIATEtransaction + unique partial index - Guest booking allowed (no login required)
- Confirmation email sent if email provided
Availability enforcement (all server-side + reflected in UI):
course_open= false → all bookings blocked- Season dates (MM-DD comparison against date's month-day)
tee_time_open/tee_time_closewindow- Sunset cutoff — astronomical calculation (
src/lib/sunset.ts, NOAA algorithm, no API key) - Private events —
is_public=0events block their time window (whole day if no start/end) - Equipment availability — capped by inventory minus same-day bookings
UI slot colors: available (white), selected start (gold), also-reserved (amber), booked (gray/strikethrough), private event (purple)
Six tiers:
- Junior Summer Pass: $100
- Young Adult: $445
- Single: $740 (+ $100 gift card new members)
- Household: $962.50 (+ $100 gift card new members)
- Driving Range – Single: $80
- Driving Range – Household: $125
Season: May 1 – Oct 31 (current year).
Payment flow:
- Google Pay / Apple Pay → Square Web Payments SDK →
/api/payments/square/process - Credit card → Square →
/api/payments/square/process - Invoice/ACH → QuickBooks →
/api/payments/quickbooks
Auto-renewal: opt-in checkbox at checkout → Square Card on File stored (square_customer_id, square_card_id on membership row).
Member number format: SLCC-YYYY-XXXXXX
Three tabs:
- Charges & Tabs — manual charges (bar_tab, cart_storage, cart_drop, invoice, other), mark paid/void
- Pending Payments — memberships with
payment_status='pending', mark paid - Renewals Due — members expiring within 30 days; bulk charge (Square card-on-file) or send reminder emails
Cron endpoint: POST /api/admin/billing/renew?secret=CRON_SECRET
Formats: luck_of_the_draw, stroke_play, stableford, scramble, best_ball, match_play
Status workflow: registration_open → registration_closed → draw_complete → scoring → completed (or cancelled)
Draw (luck_of_the_draw / scramble / best_ball):
- Admin triggers → entries shuffled with
ORDER BY RANDOM() - Teams of configurable size (2, 3, or 4)
- Leftovers distributed across existing teams (no undersized final team)
- Bulk tee time assignment: start time + interval + starting hole
Scoring: gross + net per team, auto-ranked, place assigned. Public leaderboard once scoring begins.
Public registration: /tournaments/[id] — name, email, phone, handicap. Confirmation email sent.
- Types: general, tournament, league, clinic, social
is_public = 1→ visible on public calendar, available for registrationis_public = 0→ Private: hidden from public calendar AND blocks online tee time booking during event's time window on that date (whole day if no start/end times set)- Admin can toggle Public ↔ Private per event with one click
- Admin events page (
/api/admin/events) shows ALL events; public/api/eventsshows only public ones
Types: cart, buggy, clubs
Statuses: available, out_of_service, maintenance
Fields: type, identifier, status, service_notes, make, model, year, serial_number, color, seats, fuel_type (electric/gas/push), battery_year, hours_reading, last_service_date
Availability affects tee time booking — only status='available' units count toward the pool.
Seeded dev data: 4 electric carts (Club Car, E-Z-GO), 2 gas carts (Yamaha), 4 push buggies (Clicgear, Sun Mountain, Bag Boy), 3 club sets (Callaway, Wilson).
Full-screen staff terminal. No auth required (internal use).
- Auto-refresh: 30-second polling
- Screen Wake Lock: keeps display on (
WakeLock.tsx) - Today's tee times: grouped list, click to toggle check-in, shows equipment booked
- NFC check-in: HID keyboard capture + Web NFC API (Chrome/Android)
- Member search: name/email lookup (≥2 chars, debounced)
- Walk-in logging: manual form (name, players, holes, equipment)
- Recent check-ins: last 5–10 entries with type indicator
- Stats bar: reservations today, checked in, walk-ins, NFC check-ins
NFC cards: UUID token stored on membership (nfc_token). iOS uses tap-to-open URL /desk/nfc/[token]. Admin generates/regenerates tokens at /admin/memberships.
All sends are fire-and-forget (.catch(() => {})). Silently skips if smtp_host not configured.
Emails sent for:
- Tee time booking confirmation (slots, equipment, date/time)
- Membership purchase receipt (member number, tier, amount, season dates)
- Tournament registration confirmation
- Membership renewal reminder (link to renew)
- Manifest:
public/manifest.json— standalone display, theme#2d6a4f - Service worker:
public/sw.js— caches static assets and key pages /offlinefallback page shown when navigation fails offline/deskand/tee-timesreadable offline after first visit- Data writes (bookings, payments) require a connection
- Wake Lock API used in kiosk mode
| Method | Path | Description |
|---|---|---|
| GET | /api/tee-times |
Slots + availability + season + sunset + private events |
| POST | /api/tee-times |
Book (enforces all availability rules) |
| DELETE | /api/tee-times |
Cancel booking |
| GET | /api/memberships |
Membership tiers |
| POST | /api/memberships |
Create membership |
| GET | /api/events |
Public events only |
| POST | /api/events/[id]/register |
Event registration |
| GET | /api/tournaments |
Public tournaments |
| GET | /api/tournaments/[id] |
Tournament + entries + teams |
| POST | /api/tournaments/[id]/enter |
Register for tournament |
| DELETE | /api/tournaments/[id]/enter |
Withdraw |
| GET | /api/config/public |
Square app ID + location ID for browser |
| POST | /api/auth/register |
Create user account |
| Method | Path | Description |
|---|---|---|
| GET/PUT | /api/admin/settings |
Read/write site_config |
| GET/POST/DELETE | /api/admin/users |
Admin user management |
| GET/POST/PATCH/DELETE | /api/admin/events |
All events including private |
| GET | /api/admin/tee-sheet |
Full tee sheet for date |
| GET/POST/PUT/DELETE | /api/admin/tee-times |
Booking management |
| GET/POST/PUT/DELETE | /api/admin/equipment |
Equipment inventory |
| GET/POST | /api/admin/billing/renew |
Preview/process renewals (also cron-safe with ?secret=) |
| GET/POST/PATCH | /api/admin/charges |
Member charges |
| PATCH | /api/admin/charges/[id] |
Mark paid/void |
| GET/POST | /api/admin/memberships/nfc |
NFC token management |
| POST | /api/admin/tournaments/[id]/draw |
Run blind draw |
| PUT | /api/admin/tournaments/[id]/scores |
Bulk score entry |
| POST | /api/admin/tournaments/[id]/tee-times |
Bulk tee time assignment |
| Method | Path | Description |
|---|---|---|
| GET | /api/desk/today |
Today's tee times, check-ins, stats |
| GET | /api/desk/nfc/[token] |
Look up member by NFC token |
| PUT | /api/desk/tee-times/[id] |
Toggle check-in |
| POST | /api/desk/checkin |
Log check-in (any type) |
| GET | /api/desk/member |
Member search |
| Method | Path | Description |
|---|---|---|
| POST | /api/payments/square |
Square hosted checkout |
| POST | /api/payments/square/process |
Process Web Payments token (+ card-on-file) |
| POST | /api/payments/quickbooks |
QuickBooks invoice/ACH |
Three are required:
| Variable | Value |
|---|---|
DATABASE_URL |
PostgreSQL connection string — postgresql://user:pass@host/dbname |
AUTH_SECRET |
32-byte base64 string — openssl rand -base64 32 |
NEXTAUTH_URL |
Full app URL, e.g. https://slcc.secure-computing.net |
Everything else is in the database.
Hosted on a single Vultr VM (Ubuntu 24.04 LTS, 2 vCPU / 4 GB / 80 GB NVMe). Three live environments share the VM, each with its own PostgreSQL database, PM2 process, and nginx vhost.
| Environment | Branch | URL | Port | Database |
|---|---|---|---|---|
| Production | main |
https://slcc.secure-computing.net |
3000 | swanlake |
| Boxfort (dev) | boxfort |
https://boxfort.secure-computing.net |
3001 | swanlake_boxfort |
| Gorilla (dev) | gorilla |
https://gorilla.secure-computing.net |
3002 | swanlake_gorilla |
The domain is Cloudflare-proxied. The raw server IP (45.63.69.172 / slcc-real.secure-computing.net) is used for DEPLOY_HOST in GitHub Actions secrets so SSH bypasses Cloudflare.
Every push to main, boxfort, or gorilla runs:
- Type Check —
tsc --noEmiton a GitHub runner - Tests — 22 unit + integration tests against an ephemeral postgres:16 container
- Deploy — SSH to the VM →
git pull→npm ci→npm run build→npm run db:migrate→pm2 reload(zero-downtime)
Deploy only runs if both type check and tests pass.
Manual trigger: GitHub → Actions → Deploy → Run workflow → select branch. The branch input overrides the branch the workflow was triggered on, so you can re-deploy any environment without pushing a commit.
| Secret | Value |
|---|---|
DEPLOY_HOST |
Server IP or non-proxied hostname (slcc-real.secure-computing.net) |
DEPLOY_USER |
deploy |
DEPLOY_SSH_KEY |
ED25519 private key — cat /home/deploy/.ssh/actions_deploy on the server |
# Fresh VM — run once as root
bash deploy/server-setup.sh
# Dev environments — run once as root after server-setup.sh
bash deploy/branch-setup.shserver-setup.sh installs Node 22, PostgreSQL, nginx, PM2, certbot, and UFW; generates SSH key pairs; writes .env.local; prints next steps.
branch-setup.sh provisions both dev environments: creates databases, clones the correct branch, installs, builds, starts PM2, and writes nginx vhosts.
| File | Purpose |
|---|---|
deploy/nginx.conf |
Production SSL vhost (port 443 → 3000) |
deploy/nginx-boxfort.conf |
Boxfort SSL vhost (port 443 → 3001) |
deploy/nginx-gorilla.conf |
Gorilla SSL vhost (port 443 → 3002) |
All use listen 443 ssl http2 syntax (nginx 1.24 on Ubuntu 24.04 — http2 on; directive requires nginx ≥ 1.25.1).
- Pick a name and port (e.g.
feature-x/3003) - On the server as root: create the DB, app dir,
.env.local, clone the branch, build, start PM2, write nginx vhost, issue SSL cert - Add the branch to
on.push.branchesand theifcondition indeploy-branchin.github/workflows/deploy.yml - Create and push the branch:
git checkout -b feature-x && git push -u origin feature-x