Plan: Westside Kings & Queens

plan-wkq Plan

plan deprecated

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.net shows 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)

  • random vs secrets for password generationservices/password.py uses random module. Should use secrets for cryptographic randomness. Low risk (passwords are temporary, changed on first login) but not enterprise-grade.
  • Duplicated name-parsing logicextract_first_name() in services/password.py and name splitting in services/keycloak.py both parse parent names. Should consolidate into one utility.
  • Hardcoded KEYCLOAK_BASE_URL in batch scriptscripts/create_keycloak_accounts.py still has its own KEYCLOAK_BASE_URL constant instead of reading from config.py settings. Should import from shared config.
  • Test overlap — Some tests in test_keycloak_integration.py overlap with test_account_creation.py. Could consolidate or deduplicate.
  • Stale ADMIN_EMAILS re-export — Batch script re-exports ADMIN_EMAILS that 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.js shared 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 failuresrc/routes/register/+page.svelte catch block (line 108-110) sets submitted = true even 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) and admin.py (lines 273-279). Extract to shared helper.
  • Hardcoded magic number 20000admin.py:227 uses raw cents instead of importing MONTHLY_AMOUNT_CENTS from subscriptions.py.
  • Revenue field namingtotal_revenue_cents is actually an estimate (active subs × flat rate). Consider renaming to estimated_monthly_revenue_cents.
  • DRY: _make_client test helper duplicated — identical helper across 3 test files. Extract to tests/conftest.py.
  • CORS http://localhost port mismatch — allows port 80 only, won't match Vite on :5173 or :5174. Non-blocking (dev uses k8s overlay, not local Vite).

From PR #40 (Phase 14/15 — contract UI, QA approved)

  • Unused $page importmy-players/+page.svelte imports page from $app/stores but uses window.location.search instead. Remove unused import.
  • Silent catch-to-success patternplayers/[id]/+page.svelte contract 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/month text — contract amount appears in 3 places (terms, checkbox label, success message). Extract to a constant.
  • Duplicate badge styling.badge-offered pattern duplicates the badge component structure. Consider consolidating badge variants.

From PR #98 (Phase 14 — contract backend, QA approved)

  • signature_name lacks Pydantic constraints — no min_length/max_length on the request field, but DB column is String(200). Add validation.
  • ContractStatusResponse construction 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 bannersrc/routes/+page.svelte uses 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.href navigation. 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 URLwestsidekingsandqueens and mcd-tracker overlays still reference keycloak.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 — adds basketball-api namespace to keycloak ingress allowlist. Force-merged due to CI clone infrastructure issue (not a code problem). Requires tofu apply to take effect.

From basketball-api PR #111 (Phase 11 — Stripe card registration + webhook matching)

  • Inline imports in webhook handlerParent, PaymentStatus, Registration imported inside function body at routes/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.searchParams detection for ?payment=success and ?payment=cancelled query 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 templatesservices/email.py has "Tuesday, March 24" hardcoded in both plain text and HTML. Should be config-driven or at least a constant.
  • Bare dict type alias for credentialsCredentials = dict at top of email.py. Should be a TypedDict with email and password keys for type safety.
  • Deep nesting in webhook handler — the tryout registration matching block in webhooks.py is 6+ levels deep. Could be refactored into a helper function.
  • Defensive int() parsing on metadataRegistration.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=-1 and --spi-theme-cache-themes=false added to Keycloak args for iteration. Track for removal during production hardening.
  • Off-palette hover color #1a1a1a.select-auth-box-parent:hover in westside.css:221 uses a color not in the Westside design token set. Should map to an existing token.
  • Redundant border shorthand on .card-pf — sets both border-top: 4px solid #d42026 and border-color: #d42026. The border-color applies red to all sides; likely intent was red top border only.
  • Magic number font-size: 29px#kc-header-wrapper uses pixel units. Per CSS guide philosophy, should use rem and 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 Sid to bucket policy — QA nit: the minio_s3_bucket_policy.assets_public_read policy Statement has no Sid field. Not functional but recommended for self-documentation in IAM policies.
  • force_destroy = true on public CDN bucket — QA discovered scope: the assets bucket has force_destroy = true, which means tofu destroy would 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.mjml orphaned — 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.net directly. 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(), and POST /admin/email/jersey-reminder endpoint. 8 new tests, all 464 passing.
  • EmailType reuse — QA nit: jersey reminder emails log as announcement type. Future: add a dedicated EmailType.JERSEY_REMINDER for 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_option set — checkout endpoint no longer sets player.jersey_option on 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_size column — 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/checkout accepts size — required for non-opt-out options, passed as Stripe session metadata jersey_size.
  • Webhook persists size — reads jersey_size from 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.all vs Promise.allSettled — QA nit: if sizes API fails, options fetch also fails. Should use allSettled for resilience.

From basketball-api PR #146 (Phase 11 — jersey number selection + duplicate validation, QA approved)

  • New jersey_number column — 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.