Online booking, membership, billing, and club management portal for Swan Lake Country Club in Pengilly, Minnesota. Designed to run on a subdomain such as book.swanlakecc.com.
| Layer | Technology |
|---|---|
| Framework | Next.js 15 (App Router) |
| Language | TypeScript |
| Styling | Tailwind CSS (custom Swan Lake theme) |
| Database | PostgreSQL via pg (node-postgres) |
| Auth | NextAuth.js v5 — credentials, Google OAuth, Apple Sign In |
| Nodemailer (SMTP) | |
| Payments | Square SDK + Web Payments SDK; QuickBooks Payments (ACH/invoice) |
| POS Webhooks | Toast webhook receiver (implemented); Square POS webhook (stub) |
| Process manager | PM2 |
| Web server | nginx |
- Browse available times for any date within the configured booking window
- 12-minute slots from first to last tee time (configurable)
- Party sizes 1–12; large parties automatically reserve 2–3 consecutive slots
- Concurrency-safe:
BEGIN IMMEDIATEtransaction + unique partial index prevent double-booking - Optional equipment rental per booking: power carts, walking buggies, club sets, personal cart drop
Availability rules (all configurable in Admin → Settings):
| Rule | Config key | Default |
|---|---|---|
| Season open date | season_start (MM-DD) |
05-01 |
| Season close date | season_end (MM-DD) |
10-31 |
| First tee time | tee_time_open |
07:00 |
| Last tee time | tee_time_close |
17:48 |
| Sunset cutoff | sunset_cutoff_enabled |
true |
| Hours before sunset | sunset_cutoff_hours |
2 |
| Course coordinates | course_latitude / course_longitude |
Pengilly, MN |
The sunset cutoff uses a pure astronomical calculation (no API key) — the last available slot updates daily based on the actual sunset time for the configured coordinates. It only applies if it falls earlier than tee_time_close, so special events like twilight golf can be enabled by toggling sunset_cutoff_enabled off or extending tee_time_close.
Private event blocking: any event marked as Private in Admin → Events blocks online tee time bookings during the event's time window on that date. Affected slots show as "Private Event" in purple on the booking grid.
Six tiers matching current Swan Lake CC pricing:
| Tier | Price |
|---|---|
| Junior Summer Pass (18 and under) | $100 |
| Young Adult (19–29) | $445 |
| Single | $740 (+ $100 gift card for new members) |
| Household | $962.50 (+ $100 gift card for new members) |
| Driving Range Pass – Single | $80 |
| Driving Range Pass – Household | $125 |
Payment options: Google Pay, Apple Pay, credit/debit card (Square), or invoice/ACH (QuickBooks).
Members can opt in to auto-renewal at checkout — card is tokenized via Square Card on File, never stored raw.
Six formats:
- Luck of the Draw — individual entry, admin runs blind random draw to form teams
- Scramble — captain's choice; same draw algorithm
- Best Ball — team play; draw assigns teams
- Stroke Play — individual gross/net scoring
- Stableford — individual points scoring
- Match Play — head-to-head
Admin tools: run/re-run draw, bulk-assign tee times to teams, inline score entry, auto-ranked leaderboard. Leftover players distributed evenly rather than leaving an undersized team.
- Public events calendar with type filtering (tournament, league, clinic, social)
- Online registration with party size and real-time spot tracking
- Private events — hidden from public calendar; blocks tee time booking during event hours
Automatic confirmations sent via SMTP (Nodemailer) for:
- Tee time bookings
- Membership purchases
- Tournament registrations
- Membership renewal reminders
Configured in Admin → Settings. Silently skips if smtp_host is not set.
When a tab is closed on your Toast terminal, Toast sends a CHECK_CLOSED webhook to /api/webhooks/toast. The platform:
- Verifies the HMAC-SHA256 signature using the secret from Admin → Settings → Toast POS
- Matches the tab name against active members (exact full-name match, then partial last-name match)
- Creates a Bar Tab charge in
/admin/billinglinked to the matched member (or as a standalone charge if no match) - Deduplicates automatically — if Toast retries delivery, the duplicate is silently ignored
Charges from Toast are tagged with an orange TOAST badge in the billing dashboard. Settle them the same way as manual charges (mark as paid or void).
Setup: Admin → Settings → Toast POS → enter your webhook secret and restaurant GUID. Then configure the webhook URL in the Toast Partner Portal: POST https://book.swanlakecc.com/api/webhooks/toast
- Lists memberships expiring within 30 days
- Shows which have a card on file ("auto-renewal ready") vs. not
- Bulk select → charge saved cards and create new membership record for next season
- "Send Renewal Reminders" emails members without a card on file
- Cron-safe:
POST /api/admin/billing/renew?secret=CRON_SECRETfor automated scheduling
| Section | Description |
|---|---|
| Tee Sheet | Visual day view — all 55 slots, color-coded by status, click to check in or cancel |
| Bookings | Tabular list of all tee time reservations, filter by date |
| Memberships | Full member roster, payment status, NFC card management |
| Billing | Bulk renewal processing, manual charges, renewal reminders |
| Tournaments | Create events, manage entries, run draws, assign tee times, enter scores |
| Events | Create/edit/delete events; toggle Public ↔ Private with one click |
| Equipment | Full inventory with make, model, year, serial, color, seats, fuel type, battery year, hours, last service |
| Settings | All site configuration in the database — no rebuild required |
- Color-coded: open (gray), booked (gold), checked-in (teal), multi-slot group (purple), cancelled (red), private event (purple badge)
- Click any booked slot: name, contact, players, holes, equipment, notes
- Check in, edit, or cancel directly from the panel
- Date navigation with prev/next and date picker
All configuration is stored in the database. Sections:
- Course Operation — open/closed, booking window, green fees, cart fees, season dates, tee time hours, clubhouse hours, sunset cutoff, course coordinates
- Email (SMTP) — host, port, credentials, from address
- Square Payments — access token, app ID, location ID, environment
- QuickBooks Payments — client credentials, redirect URI, environment
- Google Sign In — OAuth client ID and secret
- Apple Sign In — Services ID and private key
- Security — cron secret for billing automation
Changes take effect immediately without a server restart, except OAuth credentials (Google/Apple) which require a restart.
The service worker (public/sw.js) caches key pages so staff can continue working without a connection:
| Route | Offline behavior |
|---|---|
/desk |
Fully cached — works offline after first visit |
/tee-times |
Readable offline (no new bookings) |
/login |
Cached for credential entry |
/offline |
Fallback for uncached navigation |
/api/* |
Returns { error: "Offline" } with 503 |
Data-write operations (payments, new bookings) require an internet connection.
Full-screen kiosk for the pro shop counter. Installable as a PWA on any device.
- Screen Wake Lock — display stays on while open
- NFC Member Check-In — tap card/fob to look up member and check in to tee time
- Member Search — manual lookup by name or member number
PWA installation: iOS → Safari Share → Add to Home Screen. Android/Desktop → Chrome install prompt or address bar icon.
Three variables are required in .env.local:
| Variable | Description |
|---|---|
DATABASE_URL |
PostgreSQL connection string, e.g. postgresql://user:pass@localhost/swan_lake |
AUTH_SECRET |
Random secret for signing session tokens. Generate: openssl rand -base64 32 |
NEXTAUTH_URL |
Full URL of the app, e.g. https://book.swanlakecc.com |
All other configuration (SMTP, Square, QuickBooks, Toast, Google/Apple OAuth, fees, hours) is managed through Admin → Settings.
# Install dependencies
npm install --legacy-peer-deps
# Copy env template and set AUTH_SECRET + NEXTAUTH_URL
cp .env.local.example .env.local
# Seed development database (creates swan-lake.db with sample data)
npm run db:setup
# Start development server
npm run devOpen http://localhost:3000.
Default admin account: [email protected] / admin
Log in, add your own email as an admin at /admin/settings, then remove the default account.
Hosted on a single Vultr VM (Ubuntu 24.04 LTS, 2 vCPU / 4 GB RAM / 80 GB NVMe, ~$24/mo) running three live environments:
| Environment | Branch | URL | Port |
|---|---|---|---|
| Production | main |
https://slcc.secure-computing.net |
3000 |
| Boxfort (dev) | boxfort |
https://boxfort.secure-computing.net |
3001 |
| Gorilla (dev) | gorilla |
https://gorilla.secure-computing.net |
3002 |
Every push to main, boxfort, or gorilla triggers:
- TypeScript type check — runs on a GitHub runner
- Tests — 22 unit + integration tests against an ephemeral postgres:16 container
- Deploy via SSH —
git pull→npm ci→npm run build→npm run db:migrate→pm2 reload(zero-downtime)
Deploy only proceeds if both type check and tests pass.
Manual deploy (no commit needed): GitHub → Actions → Deploy → Run workflow → enter the target branch name.
| Secret | Value |
|---|---|
DEPLOY_HOST |
Server IP or non-proxied hostname — use the real IP, not the Cloudflare proxy |
DEPLOY_USER |
deploy |
DEPLOY_SSH_KEY |
ED25519 private key — cat /home/deploy/.ssh/actions_deploy on the server |
# Run once as root on a fresh VM — installs Node, PostgreSQL, nginx, PM2, certbot, UFW
bash deploy/server-setup.sh
# After server-setup.sh — provisions boxfort and gorilla dev environments
bash deploy/branch-setup.shserver-setup.sh prints everything you need to do in GitHub (deploy key, Actions secrets, DNS) and the exact commands to run for the first deploy.
DATABASE_URL=postgresql://swanlake:PASSWORD@localhost/swanlake
AUTH_SECRET=<openssl rand -base64 32>
NEXTAUTH_URL=https://slcc.secure-computing.net
Dev environments get their own .env.local with separate database URLs, written by branch-setup.sh.
PostgreSQL lives on the same VM. Schedule a daily pg_dump and ship it offsite:
# /etc/cron.d/swan-lake-backup
0 3 * * * deploy pg_dump swanlake | gzip > /backups/swanlake-$(date +\%F).sql.gzsrc/
app/
page.tsx # Homepage
tee-times/page.tsx # Public booking grid
memberships/page.tsx # Membership tiers + checkout
tournaments/ # Public tournament list + detail
events/page.tsx # Public events calendar
login/page.tsx # Sign in (credentials + OAuth)
register/page.tsx # Account creation
admin/
layout.tsx # Server auth guard
page.tsx # Dashboard stats
tee-sheet/page.tsx # Visual tee sheet
tee-times/page.tsx # Bookings table
memberships/page.tsx # Member roster + NFC
billing/page.tsx # Billing dashboard
tournaments/ # Tournament management
events/page.tsx # Event management
equipment/page.tsx # Equipment inventory
settings/page.tsx # Site configuration
desk/ # Counter/kiosk mode
api/
tee-times/route.ts # Public booking API (season + sunset enforced)
memberships/route.ts # Membership purchase
tournaments/ # Public tournament endpoints
events/route.ts # Public events
config/public/route.ts # Non-sensitive Square config for browser
admin/
tee-sheet/ # Admin tee sheet data
tee-times/ # Edit/cancel bookings
memberships/ # NFC management
billing/renew/ # Renewal processing + cron endpoint
tournaments/ # Draw, scores, tee time assignment
events/ # Admin event CRUD (all events incl. private)
equipment/ # Equipment CRUD
settings/ # site_config read/write
users/ # Admin user management
payments/
square/ # Hosted checkout + Web Payments token processing
square/process/ # Card-on-file + save card
quickbooks/ # QuickBooks invoice
desk/ # Desk/kiosk APIs
components/
Header.tsx
Footer.tsx
SquareWalletButtons.tsx # Google Pay / Apple Pay (fetches config at runtime)
lib/
db/
index.ts # PostgreSQL pool, typed query/execute helpers
setup.ts # Dev seed script
admin.ts # isAdminEmail(), getConfigValue()
email.ts # SMTP transactional email
sunset.ts # Astronomical sunset calculation (no API key)
square/client.ts # Square client (reads credentials from DB)
types.ts # Shared types, MEMBERSHIP_TYPES, TEE_TIME_SLOTS
auth.ts # NextAuth config (Node.js — DB-backed providers)
auth.config.ts # Edge-safe auth config (middleware only)
middleware.ts # Redirects unauthenticated users from /admin
| Method | Path | Description |
|---|---|---|
| GET | /api/tee-times?date=YYYY-MM-DD |
Slots, equipment, season info, sunset cutoff, private event blocks |
| POST | /api/tee-times |
Book a tee time |
| DELETE | /api/tee-times?id= |
Cancel a booking |
| GET | /api/memberships |
Membership tiers |
| POST | /api/memberships |
Create membership record |
| GET | /api/tournaments |
Upcoming tournaments |
| GET | /api/tournaments/[id] |
Tournament detail with entries and teams |
| POST | /api/tournaments/[id]/enter |
Register for a tournament |
| DELETE | /api/tournaments/[id]/enter |
Withdraw from a tournament |
| GET | /api/events |
Upcoming public events |
| POST | /api/events/[id]/register |
Register for an event |
| GET | /api/config/public |
Square app ID / location ID for browser |
| 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 |
Full event management including private events |
| GET/PUT | /api/admin/tee-sheet?date= |
All slots for tee sheet view |
| 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 membership renewals |
| 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 |