Plan: Westside Kings & Queens
Vision
One production app for the Westside Kings & Queens AAU basketball program. Three concerns unified in a single SvelteKit app backed by basketball-api:
- Public landing page — marketing, program info, tryout announcements
- Tryout registration — acquisition funnel: single form → info + photo + payment + disclaimer → account created
- Member area — role-based dashboards for player/parent, coach, and admin
Built for Westside first. Multi-tenant generalization only if another program wants in.
Projects & Repos Touched
| Repo | Platform | Role |
|---|---|---|
| <code>forgejo_admin/basketball-api</code> | Forgejo | FastAPI backend — auth, registration, Stripe, teams, profiles, subscriptions |
| <code>forgejo_admin/westside-app</code> | Forgejo | SvelteKit frontend — SSR dashboards, landing page, member area |
| <code>forgejo_admin/westside-playground</code> | Forgejo | HTML/CSS prototypes — design iteration before production port |
| <code>ldraney/west-side-basketball</code> | GitHub | Original landing page (GitHub Pages) — to be replaced by Phase 15 |
| <code>forgejo_admin/pal-e-deployments</code> | Forgejo | Kustomize overlays for ArgoCD |
Context
Westside Kings & Queens is a youth AAU basketball program in West Valley, Utah. 53 players registered, tryout day 1 completed March 13, 2026. Girls tryout scheduled March 21. Practices target April start. The tech platform handles registration, payments, team management, and program operations — all self-hosted on the Pal-E platform.
Current state: Auth (Keycloak), registration, Stripe payments, team placement, player profiles, subscriptions, and dashboards all shipped. But the registration flow is fragmented (4 separate steps across 2 systems) and the production app needs cleanup. We are in the playground stage — prototyping the proper UX before the production port.
Previous Plans
- Plan: Stripe Connect Payouts (completed — decisions carried forward)
- Plan: Westside App (deferred — user stories absorbed into Phases 13–17)
- Plan: Tryout Prep (March 13) (original plan — restructured into this plan, 2026-03-15)
Depends On
Pal-E platform (k8s, CI/CD, observability) — deployed and operational.
Decisions Made
| Decision | Rationale |
|---|---|
| Single SvelteKit app, not separate landing + member app | Landing page, tryouts, and member area are one user journey. One deploy pipeline. |
| Playground → production pipeline | Design in HTML/CSS first, approve on phone, then port to SvelteKit. No AI-only design. |
| Keycloak for auth | Enterprise-grade OIDC. Roles (admin, coach, player) built-in. Already deployed. |
| Stripe for all payments | Tryout fees, subscriptions, tournament fees, coach payouts. One payment provider. |
| Tiered pricing from day 1 | Cyprus girls $160/mo, LCA $180/mo, standard $200/mo. Split payment option ($100×2). |
| Registration = account creation | Single form: info + photo + pay + disclaimer → Keycloak account auto-created. No manual batch scripts. |
| Membership cancellation requires owner approval | Serious commitment — not self-serve unsubscribe. |
| SPA architecture (adapter-static) + keycloak-js | Capacitor requires static files — no Node.js in a WebView. SPA unlocks iOS app from the same codebase. keycloak-js is the official client-side adapter (PKCE, in-memory tokens). Replaces Auth.js SSR pattern. Validated by mcd-tracker. |
| Coaches see no payment info | Coaches focus on basketball, not billing. Payment visibility is admin + player/parent only. |
| Stripe portal restricted — no self-serve cancellation | Cancellation disabled via API config (bpc_1T1VesR9SdzWqVXM2TBJE5RD). Parents manage subscription changes through Marcus. |
| Enterprise billing UX — Stripe Elements, not portal redirect | Parents never leave the app. Card updates via embedded Stripe Elements. No redirect to Stripe-hosted pages. |
| Tap-tap draft interface for team assignment | Simplified team placement UX for in-person meetings. Tap player → tap team → assigned. |
| Player photos → MinIO | Enterprise storage, privacy-first. No third-party image hosts. Placeholders until photo upload is built. |
| Cash payments = admin only | Need 'mark paid manually' endpoint. Admin records cash/Venmo payments on behalf of families. |
| /teams/:id is role-aware — admin edits, coaches read-only | All authenticated users can view team details. Admin can assign/remove players inline. Coaches see roster, parent contact, playbooks — read-only. One route, two permission levels. |
| Admin stat cards: rename for clarity | "Active Players" (registered) vs "Current" (paid) is confusing. Rename to "Registered" and "Paid" or similar. |
Phases
Phase 1: Foundation (COMPLETED)
Goal: Email-gated registration, Stripe payment, waiver flow, coach onboarding, landing page.
Phase 2: Keycloak Auth (COMPLETED)
Goal: Full OIDC chain — Keycloak realm, Auth.js, role-based access, admin user management.
Phase 3: Service Deployment (COMPLETED)
Goal: Both services deployed to k8s with CI/CD (Woodpecker → Harbor → ArgoCD).
Phase 4: Tryout Day 1 (COMPLETED)
Goal: Roster, check-in, walk-ups, admin dashboard, coach mobile roster. 53 players, zero downtime.
Phase 5: Team Placement (COMPLETED)
Goal: Team model, admin draft board, coach filtered roster, public teams overview.
Phase 6: Stripe Subscriptions (COMPLETED)
Goal: $200/month recurring billing, admin payment dashboard, Stripe Customer Portal.
Phase 7: Player Profiles (COMPLETED)
Goal: Profile CRUD, role-based edit permissions, coach→profile links, endpoint auth lockdown.
Phase 8: Design System (COMPLETED)
Goal: CSS design tokens, dark theme fix, mobile-first nav, brand color alignment.
Phase 9: Frontend Redesign (COMPLETED)
Goal: Landing page ported from GitHub, post-login role redirect, dashboard ports from playground.
Phase 10: Playground — Complete App Prototype (COMPLETED)
Goal: Complete app prototype — 11 static HTML/CSS pages covering public landing, registration, member dashboards, billing, and admin ops. Design source of truth for the SPA rebuild (Phase 15).
Phase 11: Girls Tryout — March 24 (IN PROGRESS)
Goal: Girls tryout operations for March 24, 2026. Registration link wired, date/location confirmed (Kongo gym, 4-5:30 PM), ops guide for day 2.
BLOCKERS RESOLVED: Registration email-mismatch fixed (token-based lookup). Auto Keycloak account creation on paid registration. Account script names fixed. All via PR #94. Remaining: Playground tryouts page fixes (date, location, registration URL), ops guide, Marcus decisions (gym confirmed: Kongo, pricing TBD).
Phase 12: Unified Registration Flow (NOT STARTED)
Goal: Single form: info + photo + pay + disclaimer → auto-creates Keycloak account. Fix email-mismatch bug.
Phase 13: Practice Schedule (NOT STARTED)
Goal: Parents see weekly practice schedule (no login, mobile-first). Owners manage schedule + gyms. US-1, 2, 3, 11.
Phase 14: Billing Tiers & Contracts (NOT STARTED)
Goal: Tiered pricing ($160/$180/$200), split payment ($100×2), contract generation + digital signature. US-6, 8.
Phase 15: Production Port — SPA Rebuild (IN PROGRESS)
Goal: Replace current SSR westside-app entirely. Rebuild as SPA (adapter-static + keycloak-js). Port all 11 playground pages. Capacitor-ready from day one — one codebase for web + future iOS app.
Phase 16: Expense Tracking (NOT STARTED)
Goal: Budget by category, log expenses, Stripe revenue auto-pull, budget vs actual dashboard. US-4, 5, 6, 7.
Phase 17: Coach Payouts (NOT STARTED)
Goal: Stripe Connect Express payouts, 1099-NEC auto-filing, coach pay calculation. US-10.
Phase 18: Integration Validation (NOT STARTED)
Goal: E2E tests (Playwright), Prometheus app metrics, Blackbox probes, Grafana alerts. DORA CFR + MTTR.
Phase 19: Coach Playbooks (NOT STARTED)
Goal: Coaches upload playbooks for their team to study. Team-scoped file storage + access control.
Key Files
| File | Repo | What |
|---|---|---|
| <code>src/routes/</code> | westside-app | SvelteKit routes: /, /admin, /coach, /player, /teams, etc. |
| <code>src/app.css</code> | westside-app | Design system tokens |
| <code>app/</code> | basketball-api | FastAPI app: routes, models, auth, Stripe |
| <code>alembic/</code> | basketball-api | Database migrations |
Verification
- Phase 11: Girls tryout runs smoothly March 21. Registrations create accounts automatically.
- Phase 13: Parent opens schedule on phone → sees practice days, times, gym. No login.
- Phase 14: Admin creates subscription with tier → player sees tier-appropriate billing.
- Phase 15:
westsidekingsandqueens.tail5b443a.ts.netshows production landing page.
Epilogue
QA nits and discovered scope from approved PRs. Each item becomes a subphase or TODO when prioritized.
From PR #94 (Phase 11 — auto Keycloak account creation)
randomvssecretsfor password generation —services/password.pyusesrandommodule. Should usesecretsfor cryptographic randomness. Low risk (passwords are temporary, changed on first login) but not enterprise-grade.- Duplicated name-parsing logic —
extract_first_name()inservices/password.pyand name splitting inservices/keycloak.pyboth parse parent names. Should consolidate into one utility. - Hardcoded
KEYCLOAK_BASE_URLin batch script —scripts/create_keycloak_accounts.pystill has its ownKEYCLOAK_BASE_URLconstant instead of reading fromconfig.pysettings. Should import from shared config. - Test overlap — Some tests in
test_keycloak_integration.pyoverlap withtest_account_creation.py. Could consolidate or deduplicate. - Stale
ADMIN_EMAILSre-export — Batch script re-exportsADMIN_EMAILSthat may drift from the shared service. Single source of truth needed.
From PR #37 (Phase 15 — SPA rebuild, QA approved)
getInitials()duplicated 7 times — identical function copy-pasted across 7 route files. Extract to$lib/utils.jsshared module.- Zero test coverage — no unit or integration tests for any SPA route. Soft blocker — needs follow-up issue for test scaffolding.
- Register page shows success on API failure —
src/routes/register/+page.sveltecatch block (line 108-110) setssubmitted = trueeven when the API call fails. User sees "Registration Complete" when it didn't complete. - Accessibility gaps — interactive elements (clickable divs, radio groups) missing ARIA labels and keyboard handlers.
From PR #96 (Phase 15 — basketball-api SPA endpoints, QA approved x2)
- DRY: player status derivation duplicated — logic in
account.py(lines 74-83) andadmin.py(lines 273-279). Extract to shared helper. - Hardcoded magic number
20000—admin.py:227uses raw cents instead of importingMONTHLY_AMOUNT_CENTSfromsubscriptions.py. - Revenue field naming —
total_revenue_centsis actually an estimate (active subs × flat rate). Consider renaming toestimated_monthly_revenue_cents. - DRY:
_make_clienttest helper duplicated — identical helper across 3 test files. Extract totests/conftest.py. - CORS
http://localhostport mismatch — allows port 80 only, won't match Vite on:5173or:5174. Non-blocking (dev uses k8s overlay, not local Vite).
From PR #40 (Phase 14/15 — contract UI, QA approved)
- Unused
$pageimport —my-players/+page.svelteimportspagefrom$app/storesbut useswindow.location.searchinstead. Remove unused import. - Silent catch-to-success pattern —
players/[id]/+page.sveltecontract sign handler shows success even on API failure. Add TODO comment marking this as demo-only behavior. - Hardcoded mock ID 999 — mock player uses ID 999 which could collide with real data at scale. Use a UUID or clearly impossible ID.
- Repeated
$200/monthtext — contract amount appears in 3 places (terms, checkbox label, success message). Extract to a constant. - Duplicate badge styling —
.badge-offeredpattern duplicates the badge component structure. Consider consolidating badge variants.
From PR #98 (Phase 14 — contract backend, QA approved)
signature_namelacks Pydantic constraints — nomin_length/max_lengthon the request field, but DB column isString(200). Add validation.ContractStatusResponseconstruction duplicated — same response building in GET and POST endpoints. Extract helper like_player_profile_response.- GET contract allows any authenticated user — any logged-in user can view any player's contract status. Consistent with existing
get_player_profile, but worth tracking as a design choice.
From westside-app PR #52 (Phase 11/15 — port fix, Stripe redirect, tryout date)
- Hardcoded "This Tuesday" in tryout banner —
src/routes/+page.svelteuses static day-of-week text that will go stale after March 24. Should use a date-based computed value or config. - Submit button re-enables during Stripe redirect — after card payment submits, the button briefly re-enables during
window.location.hrefnavigation. Cosmetic only — no functional impact.
From pal-e-deployments PR #40 (Phase 11 — internal Keycloak URL for basketball-api)
- Other overlays still use external Keycloak URL —
westsidekingsandqueensandmcd-trackeroverlays still referencekeycloak.tail5b443a.ts.net. Tracked as pal-e-deployments issue #41. Each migration needs a corresponding NetworkPolicy rule in pal-e-platform.
From pal-e-platform PR #120 (Phase 11 — keycloak NetworkPolicy for basketball-api)
- Clean merge, no nits. Single-line addition to
terraform/network-policies.tf— addsbasketball-apinamespace to keycloak ingress allowlist. Force-merged due to CI clone infrastructure issue (not a code problem). Requirestofu applyto take effect.
From basketball-api PR #111 (Phase 11 — Stripe card registration + webhook matching)
- Inline imports in webhook handler —
Parent,PaymentStatus,Registrationimported inside function body atroutes/webhooks.py:198. Should be moved to module-level for consistency. - Email-based webhook matching is interim — Phase 4 of the registration plan replaces this with metadata-based Stripe Checkout Sessions. The current email matcher is correct but fragile for edge cases (e.g., parent changes email between registration and payment).
From westside-app PR #54 (Phase 11 — payment success/cancel pages)
- Clean merge, QA approved with no nits. Added
$page.url.searchParamsdetection for?payment=successand?payment=cancelledquery params. Success page tells parent to check email for credentials. Cancel page offers retry link.
From basketball-api PR #113 (Phase 11 — dynamic Stripe Checkout Sessions)
- Hardcoded tryout dates in email templates —
services/email.pyhas "Tuesday, March 24" hardcoded in both plain text and HTML. Should be config-driven or at least a constant. - Bare
dicttype alias for credentials —Credentials = dictat top of email.py. Should be a TypedDict withemailandpasswordkeys for type safety. - Deep nesting in webhook handler — the tryout registration matching block in
webhooks.pyis 6+ levels deep. Could be refactored into a helper function. - Defensive
int()parsing on metadata —Registration.id == int(registration_id)could raise ValueError on corrupted metadata. Add a try/except or validate before casting.
From basketball-api PR #128 (Phase 11/14 — generic checkout system, QA approved x4)
- Webhook idempotency — no guard against re-processing an already-paid order on Stripe retries. Could double-process.
- Duplicate order prevention — no check for existing orders for same player+product. Parent could create multiple orders.
- Select field value validation — custom_data validation checks key presence but not whether values match the allowed options list.
- N+1 on orders listing — GET /checkout/orders lacks joinedload for product relationship.
- Hardcoded parent.players[0] — checkout uses first player. Multi-player families need a player_id parameter.
From westside-app PR #56 (Phase 11 — checkout frontend + CI fix + a11y compliance)
- Card keyboard accessibility — checkout card click targets use div onclick without role/tabindex. Not keyboard navigable.
- Duplicated submit button block — submit button markup appears twice (with and without custom fields). Could be extracted.
- Success page dead-end — no navigation back to the main site after order confirmation.
- Woodpecker clone fix applied broadly — git init + fetch SHA pattern should be propagated to all repos (basketball-api also updated).
From pal-e-platform PR #130 (Phase 15 — Westside Keycloak login theme, QA approved)
- Dev-only theme cache flags —
--spi-theme-static-max-age=-1and--spi-theme-cache-themes=falseadded to Keycloak args for iteration. Track for removal during production hardening. - Off-palette hover color
#1a1a1a—.select-auth-box-parent:hoverinwestside.css:221uses a color not in the Westside design token set. Should map to an existing token. - Redundant border shorthand on
.card-pf— sets bothborder-top: 4px solid #d42026andborder-color: #d42026. Theborder-colorapplies red to all sides; likely intent was red top border only. - Magic number
font-size: 29px—#kc-header-wrapperuses pixel units. Per CSS guide philosophy, should useremand be documented or derived from the design system.
From pal-e-platform PR #132 (Phase 11 — MinIO public CDN for email images, QA approved)
- Add
Sidto bucket policy — QA nit: theminio_s3_bucket_policy.assets_public_readpolicy Statement has noSidfield. Not functional but recommended for self-documentation in IAM policies. force_destroy = trueon public CDN bucket — QA discovered scope: theassetsbucket hasforce_destroy = true, which meanstofu destroywould delete all jersey images. Now that the bucket serves as a public CDN, consider removing this flag or adding backup.
From westside-emails PR #2 + basketball-api PR #135 (Phase 11 — MJML email service, QA approved)
- New repo:
westside-emails— MJML email template system. Base layout with<mj-include>partials (head, header, footer). First template:jersey-reminder.mjml. Build script, 8-assertion test suite, Woodpecker CI pipeline. base-layout.mjmlorphaned — QA nit: the base layout uses the same partials but is not referenced by any template. It exists as a reference/starting point. May want to either use it as the actual wrapper or document it as a reference-only file.- Hardcoded MinIO hostname in templates — QA nit: jersey images reference
minio-api.tail5b443a.ts.netdirectly. If the hostname changes, all templates need updating. Consider a{{cdn_base_url}}variable. - basketball-api integration — Added
load_email_template()function,send_jersey_reminder_email(), andPOST /admin/email/jersey-reminderendpoint. 8 new tests, all 464 passing. EmailTypereuse — QA nit: jersey reminder emails log as announcement type. Future: add a dedicatedEmailType.JERSEY_REMINDERfor distinguishable audit trail.- Path traversal guard — QA nit: template loader should validate filenames to prevent directory traversal. Not exploitable in current usage but good defense-in-depth.
From basketball-api PR #140 (Phase 11 — jersey_option timing fix, QA approved)
- Removed premature
jersey_optionset — checkout endpoint no longer setsplayer.jersey_optionon session creation. Webhook sets it on Stripe payment confirmation. Prevents abandoned checkouts from appearing as orders. - Multi-player family gap — QA discovered: webhook updates only the first player from Stripe metadata, but checkout sets all siblings to
pending. Track separately.
From basketball-api PR #142 (Phase 11 — jersey size selection backend, QA approved)
- New
jersey_sizecolumn — enum (YS, YM, YL, YXL, AS, AM, AL, AXL) on players table. Migration 015. GET /jersey/sizes— new endpoint returning available sizes with human-readable labels.POST /jersey/checkoutacceptssize— required for non-opt-out options, passed as Stripe session metadatajersey_size.- Webhook persists size — reads
jersey_sizefrom Stripe metadata on payment confirmation, saves to player. - Triple size definition — QA nit: sizes defined in enum, route, and validation. Could derive from enum.
From westside-app PR #66 (Phase 11 — jersey size dropdown frontend, QA approved)
- Size dropdown on jersey cards — each non-opt-out card gets a size selector populated from
GET /jersey/sizes. Order button disabled until size picked. - Fallback sizes — hardcoded fallback if API unreachable. QA nit: could go stale if sizes change.
Promise.allvsPromise.allSettled— QA nit: if sizes API fails, options fetch also fails. Should useallSettledfor resilience.
From basketball-api PR #146 (Phase 11 — jersey number selection + duplicate validation, QA approved)
- New
jersey_numbercolumn — String(2), nullable. Migration 016. Supports 0, 00, 1-99 per AAU/NFHS rules. - Per-division uniqueness — checkout rejects duplicate numbers within same division (boys/girls). Cross-division duplicates allowed.
GET /jersey/taken-numbers?division=— returns claimed numbers for frontend to show unavailable.- Application-level uniqueness only — QA nit: no DB unique constraint. Race condition at scale but acceptable for current load.
- Webhook doesn't re-validate number — QA nit: trusts Stripe metadata. Defense-in-depth gap.