● Zion Boggan zionboggan.com ↗

Add implementation plan for the stepping app web MVP

7aef50e   Zion Boggan committed on Jun 24, 2026 (1 month ago)
STEPPING_APP_IMPLEMENTATION_PLAN.md +325 -0
@@ -0,0 +1,325 @@
+# Stepping App - Implementation Plan
+
+The Stepping App is a city-first community platform for HBCU / Divine Nine (NPHC) stepping - the percussive step-show art form (step shows, strolls, competitions, classes, teams, DJs, promoters) - shipping web-first as an installable PWA, then native iOS/Android, with live streaming of step shows as the headline feature. The build is one TypeScript spine (Next.js + tRPC + Zod + Drizzle over Supabase Postgres) with the hard, undifferentiated media work (transcode, adaptive delivery, low-latency live, auto-VOD) bought from a single managed provider behind a swappable interface. Everything users browse is scoped by `city`, anyone with an account can post (optimistic publish, soft moderation), and roles/verification/monetization are pre-modeled as dormant columns and pluggable services so they switch on later without a migration or rewrite. Launch sequence is Atlanta + DC first, then Houston + NC Triangle, with Memphis as the home-market pilot.
+
+---
+
+## Recommended Stack
+
+One decisive stack. No optionality at the top level; optionality lives behind interfaces.
+
+| Layer | Choice | Why |
+|---|---|---|
+| **Web / PWA** | **Next.js 15 (App Router) + React + TypeScript + Tailwind + shadcn/ui**, installable PWA via `next-pwa` | Server-rendered discovery pages are Google-indexable (critical for "Atlanta step show 2026" search), fast on mobile data, app-like on day one |
+| **API** | **tRPC** colocated in the Next app, with **Zod** schemas in a shared package | End-to-end type safety into web and future native; no separate API service to run for MVP |
+| **Async / queue** | **Inngest** (or Upstash QStash) | Serverless, scale-to-zero; handles webhooks fan-out, push, scheduled city-data refresh |
+| **Database** | **Supabase Postgres + PostGIS**, **Drizzle ORM** | DB + Auth + Storage + RLS + real geo ("near venue") in one managed service; typed migrations from the shared package |
+| **Auth** | **Supabase Auth** (email magic-link + Google + Apple OAuth) | Apple sign-in is mandatory for the future iOS build; JWTs flow straight into tRPC and RLS |
+| **Image storage** | **Supabase Storage** (S3-compatible, behind CDN) | Flyers, avatars, team/profile photos; RLS-aware, cheap |
+| **Video + Live (VOD, transcode, RTMP/SRT/WHIP ingest, LL-HLS, auto-record)** | **Mux** | See decision below |
+| **Live chat / realtime** | **Supabase Realtime** | Already on Supabase; live chat, viewer counts, reaction bursts with no websocket server to run |
+| **Text moderation** | **OpenAI Moderation API** (free) + slur/profanity denylist | Comments, captions, descriptions, chat |
+| **Visual moderation** | **Hive Moderation** (or AWS Rekognition) | Per-asset nudity/violence/CSAM scoring in the webhook handler |
+| **Maps / places** | **Google Maps Platform** (Places + Maps SDK), cached into Postgres | Geocoding, travel guide, "near venue"; cache aggressively to control spend |
+| **Web hosting** | **Vercel** | First-party Next.js host: preview deploys, edge CDN, image optimization, zero-config |
+| **Data plane** | **Supabase Cloud** | Don't self-host the DB for an always-on consumer app |
+| **Homelab role** | Staging, scheduled **city-data ingest** job, backups, documented self-host fallback | Keep production user-facing infra in the cloud so it survives a homelab reboot |
+| **Native (Phase 2)** | **Expo (React Native)** in a **Turborepo monorepo** sharing `packages/` | Share the logic, fork the shell - see Architecture |
+
+### Decision: Mux for the media + live plane (resolving Mux vs Cloudflare Stream)
+
+The architecture contributor chose **Mux**; the media contributor leaned **Cloudflare Stream** on predictable per-minute pricing. **We pick Mux**, decisively, for three reasons:
+
+1. **DX for a solo operator is the binding constraint, not the per-minute bill.** Pre-monetization volume is low; the cost delta between providers is single-digit dollars per show. Mux's API ergonomics (clean asset IDs, signed playback, `stream.active`/`asset.ready` webhooks, auto-VOD on stream end, storyboards/thumbnails) save real build-and-debug time, which is the scarce resource here.
+2. **One provider covers VOD + Live + auto-record + WHIP browser ingest + RTMP/SRT pro ingest** - the same single integration both contributors wanted, just landing on the better-DX vendor.
+3. **The cost concern is fully neutralized by the seam.** All media access goes through one internal `MediaService` interface. Mux today; Cloudflare Stream (cheaper at scale) or a homelab MediaMTX/FFmpeg fallback tomorrow is a one-adapter change, not an app change. We document Cloudflare as the at-scale swap target the moment delivery cost becomes the dominant line item (which lands after ticketing/ads revenue exists).
+
+Cost posture is unchanged either way: **zero idle cost**, usage-based, no always-on encoding fleet - perfect for a spiky homecoming-night events calendar.
+
+---
+
+## Architecture
+
+**Shape:** a single TypeScript codebase that is web-first now and native-ready later, with managed cloud for everything user-facing and the homelab reserved for staging, scheduled jobs, and documented fallback.
+
+```
+ ┌─────────────────────────────────────────────┐
+ Browser / PWA ───────▶│ Next.js 15 on Vercel (SSR discovery pages) │
+ (Expo RN later) ─────▶│ + tRPC API routes + shared packages/ core │
+ └───────┬───────────────┬───────────────┬──────┘
+ │ Drizzle/RLS │ MediaService │ events
+ ▼ ▼ ▼
+ ┌────────────────────┐ ┌───────────┐ ┌────────────┐
+ │ Supabase Postgres │ │ Mux │ │ Inngest │
+ │ +PostGIS +Auth │ │ Video+Live│ │ queue/cron │
+ │ +Storage +Realtime │ │ (RTMP/SRT │ │ (webhooks, │
+ └────────────────────┘ │ /WHIP → │ │ push, │
+ ▲ │ LL-HLS, │ │ ingest) │
+ │ │ auto-VOD) │ └─────┬──────┘
+ ┌─────────┴────────┐ └─────┬─────┘ │
+ │ Homelab CT (215) │ │ │
+ │ staging · city │◀── Google Places ───────┘
+ │ ingest · backups │ (cached → Postgres)
+ │ video fallback │
+ └──────────────────┘
+```
+
+### Key architectural decisions
+
+- **Monorepo from day one (Turborepo).** `apps/web` (Next.js) ships now. `packages/` holds the API client, types, Zod schemas, Drizzle schema, business logic, and design tokens. `apps/mobile` (Expo) is added in Phase 2 and consumes the **same** `packages/` core. **We share the logic, not the literal UI** - no React Native Web / Solito / Flutter compromise, because the web app's SSR/SEO value is too high to sacrifice for pixel-for-pixel code share. Two view layers, one brain.
+- **Read-heavy discovery surfaces are React Server Components** (events, promoters, city guides, team/DJ profiles) so they are crawlable and fast. Interactive surfaces (post forms, live player + chat, search) are client components.
+- **The public API stays serverless** so it scales to zero between events and absorbs homecoming-night spikes without capacity planning. Heavy/async work (Mux webhooks → asset state transitions, push fan-out, scheduled city-data refresh) runs through Inngest, promoted to a dedicated homelab worker only if volume ever demands it.
+- **Live streaming is fully managed.** A broadcaster goes live via (a) **pro path:** an issued RTMP/SRT stream key into OBS / a venue encoder, or (b) **casual path:** a browser/phone **WHIP (WebRTC)** "Go Live" button so any attendee can stream without OBS. Mux transcodes both to a low-latency **LL-HLS** ladder (3-8s glass-to-glass, the right tier for watch-and-chat). Viewers play LL-HLS with **hls.js** (native HLS on Safari/iOS). On disconnect, Mux **auto-records to a VOD asset** that drops into the Videos feed through the normal moderation pipeline. Live chat runs over Supabase Realtime keyed to the stream id, not a hand-built websocket server.
+- **Provider-agnostic seams** isolate every buy-vs-build vendor: `MediaService` (upload, asset, live, terminate, webhook, sign token) and `ModerationService` (checkText, checkAsset, recordReport, evaluateThresholds). Swapping Mux → Cloudflare/self-host, or one moderation vendor for another, is a single adapter.
+- **Roles, moderation, and monetization are pre-wired, dormant.** RLS policies and `role`/`status`/`verified_at` columns exist now (allow-all today); Stripe and listing/ticketing tables slot into tRPC later. They are switches, not migrations.
+
+---
+
+## Data Model
+
+Consolidated from the data-model and roadmap contributions. Postgres (Supabase), Drizzle, PostGIS. The reconciliation: we keep **explicit typed entities** (Event, Video, Promoter, etc.) rather than one generic `listing` blob - but every user-creatable entity shares two **mixins** so they behave uniformly (this captures the roadmap's "one content model" intent without losing query clarity and relational integrity).
+
+### Conventions (every entity)
+- **PK:** `id` UUID v7 (time-sortable). **Audit:** `created_at`, `updated_at` (timestamptz).
+- **Ownership mixin** (every user-creatable entity): `created_by` (FK User, nullable for seed/system rows) + transferable `owner_id` (FK User; `= created_by` at creation). Lets a fan stub a Promoter/DJ/Chapter and the real party claim it later.
+- **Moderatable mixin** (every user-creatable entity): `status` enum(`active`,`pending`,`hidden`,`removed`) default `active` (optimistic publish), `is_claimed` bool, `verified_at` (nullable), `flag_count` int (denormalized), `quality_score` numeric (nullable, future ranking). Soft-delete only - never hard delete.
+- **City scoping:** `city_id` (FK City) on every browseable entity. User and Video are *optionally* scoped (nullable). This is the single axis the whole Discover experience filters on.
+- **Geo:** entities with a physical location carry `lat`, `lng` for "near venue" queries (PostGIS).
+
+### Identity & accounts
+- **User** - `display_name`, `handle` (unique), `email` (unique, nullable for OAuth-only), `auth_provider`, `password_hash` (nullable), `avatar_url`, `bio`, `home_city_id` (nullable), `role` enum(`member`,`moderator`,`admin`) default `member`, `trust_tier` int (drives rate limits + auto-publish vs queue), `affiliation_org_id` (nullable), `is_active`, `last_seen_at`. *(MVP: everyone is `member`; the columns exist so roles/verification drop in with zero migration.)*
+- **Organization** (reference data, seeded with the Divine Nine, **not** user-posted) - `name`, `nickname`, `kind` enum(`fraternity`,`sorority`), `letters`, `founded_year`, `colors`, `crest_url`.
+
+### Community orgs (city-scoped, claimable)
+- **Chapter** - `organization_id`, `city_id`, `name`, `campus`, `chapter_type` enum(`undergrad`,`alumni`), `description`, `logo_url`, `socials` (jsonb). *Moderatable.*
+- **StepTeam** - `name`, `city_id`, `organization_id` (nullable - independent teams exist), `chapter_id` (nullable), `description`, `logo_url`, `socials` (jsonb), `team_type` enum(`greek`,`independent`,`youth`,`collegiate`). *Moderatable.*
+- **ChapterMembership** (join User↔Chapter) - `user_id`, `chapter_id`, `member_role` enum(`member`,`leader`,`historian`), `status` enum(`pending`,`active`).
+
+### Industry profiles (city-scoped, claimable)
+- **Promoter** - `name`, `city_id`, `bio`, `logo_url`, `website`, `socials` (jsonb), `contact_email`, `contact_phone`. *Moderatable.*
+- **DJ** - `name`, `city_id` (nullable; DJs travel), `bio`, `avatar_url`, `genres` (text[]), `socials` (jsonb), `booking_contact`. *Moderatable.*
+- **Instructor** - `name`, `city_id`, `bio`, `avatar_url`, `credentials`, `contact`. *Moderatable.*
+
+### Events & places
+- **Venue** - `name`, `city_id`, `address`, `lat`, `lng`, `capacity` (nullable), `venue_type` enum(`arena`,`club`,`campus`,`outdoor`,`studio`,`other`). *Moderatable.* Anchor for "near venue" travel queries.
+- **Event** (central entity) - `title`, `description`, `event_type` enum(`step_show`,`stroll`,`competition`,`homecoming_showcase`,`special_event`,`concert_promo`,`set`,`other`), `city_id`, `venue_id` (nullable), `venue_freetext` (nullable), `starts_at`, `ends_at`, `timezone`, `is_recurring`, `recurrence_rule` (RRULE, nullable), `promoter_id` (nullable), `host_chapter_id` (nullable), `cover_image_url`, `ticket_url` (external for now), `price_text`, `is_free`, `is_live_eligible`. *Moderatable.*
+- **EventParticipant** (join Event↔StepTeam) - `event_id`, `step_team_id`, `role` enum(`performer`,`competitor`,`host`), `placement` (nullable int).
+- **EventDJ** (join Event↔DJ) - `event_id`, `dj_id`, `slot` (nullable).
+- **Class** - `title`, `description`, `city_id`, `venue_id` (nullable), `instructor_id` (nullable), `step_team_id` (nullable host), `skill_level` enum(`beginner`,`intermediate`,`advanced`,`all`), `schedule_text`, `starts_at`, `recurrence_rule`, `price_text`, `is_free`, `signup_url`. *Moderatable.*
+
+### Media & live
+- **MediaAsset / Video** - `title`, `description`, `city_id` (nullable), `source_type` enum(`upload`,`live_vod`,`youtube`,`vimeo`,`other`), `provider_asset_id` / `source_url`, `thumbnail_url`, `duration_sec`, `category` enum(`tutorial`,`recap`,`performance`,`stroll`,`other`), `event_id` (nullable), `step_team_id` (nullable), `view_count`, plus media state machine field `media_state` enum(`pending_upload`,`uploaded`,`transcoded`,`in_review`,`published`,`rejected`,`taken_down`). *Moderatable.* (Bytes live with Mux; only metadata + provider IDs live in Postgres.)
+- **LiveStream** - `title`, `event_id` (nullable - standalone sets allowed), `city_id`, `status` enum(`scheduled`,`idle`,`live`,`ended`,`canceled`), `scheduled_at`, `started_at`, `ended_at`, `provider_input_id`, `ingest_url`, `stream_key` (write-only secret), `playback_url` (LL-HLS), `provider` enum(`mux`,`cloudflare`,`self`,`other`), `recorded_video_id` (nullable VOD), `viewer_peak`. *Moderatable.*
+
+### Travel guide (per-city, per-venue)
+- **TravelPlace** - `name`, `city_id`, `place_type` enum(`hotel`,`restaurant`,`mall`,`attraction`,`entertainment`,`hospital`), `address`, `lat`, `lng`, `phone`, `website`, `price_tier` (nullable), `notes`, `external_source` enum(`manual`,`google_places`,`other`), `external_ref` (nullable), `near_venue_id` (nullable). *Moderatable.* (Same table holds curated and Places-pulled rows.)
+
+### Geography (reference, drives all scoping)
+- **City** - `name`, `slug` (unique, used in URLs: `/atlanta/events`), `state`, `metro_label` (e.g. "DMV", "NC Triangle"), `lat`, `lng`, `timezone`, `is_launched` bool (gates public browsability), `tier` int (launch ordering). *(A `Metro` parent table is deferred; `metro_label` text covers DMV/Triangle grouping until a real join is needed.)*
+
+### Cross-cutting: moderation & engagement
+- **Comment** (polymorphic) - `user_id`, `body`, `target_type` enum + `target_id`, `parent_comment_id` (nullable threading). *Moderatable.*
+- **Flag** - `reporter_id`, `target_type` + `target_id` (any Moderatable, incl. Comment/LiveStream), `reason` enum(`spam`,`abuse`,`wrong_info`,`sexual`,`violence`,`copyright`,`duplicate`,`off_topic`,`other`), `note`, `status` enum(`open`,`reviewed`,`dismissed`,`actioned`). Increments target `flag_count`; distinct-reporter + reporter-trust weighting drives auto-hide thresholds.
+- **ClaimRequest** - `requester_id`, `target_type` + `target_id` (Promoter/DJ/Chapter/StepTeam/Venue), `evidence`, `status` enum(`pending`,`approved`,`rejected`). On approval: transfer `owner_id`, set `is_claimed`/`verified_at`.
+- **AuditLog** - `actor_id`, `action`, `target_type` + `target_id`, `reason`, `created_at`. Every moderation action writes here (appeals + DMCA records).
+
+### Future-proofing hooks (defined, unused in MVP)
+- **Monetization-ready:** `ticket_url`/`price_text`/`is_free` on Event and Class (external links now); future `Listing`/`Order`/`Subscription` tables attach to Promoter via `owner_id`; a `featured` flag + `stripe_account_id` slot onto profiles. No paywall logic in MVP.
+- **Native-ready:** all entities API-first (UUID PKs, no web-only assumptions); media payloads carry provider/source IDs that serve web and native players identically.
+
+---
+
+## MVP Scope & Phased Roadmap
+
+Guiding rule: **the smallest thing that makes a traveling stepper or a local D9 member open the app twice is an events feed they trust.** Everything else hangs off that. Live streaming is sequenced *last among features* despite being the headline - it's the most operationally heavy (cost, abuse, bandwidth) and must not gate the rest of the product.
+
+### Phase 0 - Web MVP (ship in weeks, Atlanta + DC only)
+**Goal:** be the place to find the next step show in ATL/DC; live and shareable fast.
+
+**In:**
+- City switcher hardcoded to **Atlanta + DC**.
+- **Events feed** per city (step shows, strolls, competitions, special/concert events): title, date/time, venue + address, flyer, description, type tag.
+- **Event detail** with a Google Maps deep-link (not embedded SDK yet) and share link.
+- **Open posting** via simple forms (lightweight account: magic-link/OAuth): event, video link, promoter/DJ/team/class listing.
+- **Videos** = embedded YouTube/IG/TikTok links with title + city tag (no native upload/hosting yet - cheap, high perceived value).
+- **Comments** under events and videos.
+- **Directories** (flat lists, real typed entities): Promoters, DJs, Step teams/chapters, Classes.
+- **Minimal moderation:** single admin (Zion) view of new posts; soft-hide; "report" link. (Required because posting is open.)
+- **PWA basics:** installable, responsive, mobile-first, offline shell.
+
+**Invisible must-dos (prevent rewrites):** the Ownership + Moderatable mixins, dormant `role`/`trust_tier`/`status` columns, the shared Venue/TravelPlace geo structure, and the `MediaService`/`ModerationService` seams stubbed.
+
+**Deferred:** native video upload/hosting, native live, ticketing/RSVP, interactive maps, verified roles, push, monetization.
+
+### Phase 1 - Community depth + travel guide (web; expand cities)
+**Goal:** make it sticky and worth traveling with; expand once the model is proven.
+- **Per-city travel guide** (hotels, restaurants, malls, attractions/entertainment, hospitals near venue) - **curated/manual** seed per city, optional Google Places enrichment.
+- **Real profiles** for promoters/DJs/teams/chapters with their events auto-listed.
+- **Search + filters** across events/videos/listings (city, type, date).
+- **Proper accounts:** saved/favorited events, "my city," basic profile.
+- **City expansion:** add **Houston** + **NC Triangle (Greensboro/Durham)**; stand up **Memphis** as pilot/home market. (Data-entry bound, not engineering.)
+- **Light moderation tooling:** proper admin queue, hide/restore, spam heuristics, grant a trusted "city editor" hide power (flips on the dormant `role`).
+- **Comment threading** + report → queue.
+
+**Deferred:** live streaming, native apps, payments.
+
+### Phase 2 - Live streaming + native apps (iOS + Android)
+**Goal:** deliver the headline live feature and reach the app stores.
+- **Native video upload + transcode** via Mux (`MediaService` already stubbed): direct-to-Mux upload, auto-HLS, the asset state machine goes live.
+- **Live streaming** of step shows: pro RTMP/SRT key path + casual WHIP browser path; LL-HLS playback; **auto-VOD** into the Videos feed.
+- **Live chat** over Supabase Realtime; viewer counts; reactions.
+- **Native iOS + Android via Expo** sharing `packages/` core - re-skin screens, reuse logic; add push, camera/stream access, store presence.
+- **Push notifications:** "a show just went live in your city," "new event near you."
+- **Stream moderation:** report-threshold auto-pause, kill-switch endpoint, mandatory account to go live, post-hoc VOD review.
+
+### Phase 3 - Monetization (switch-flipping on a monetization-ready schema)
+- **Promoter / featured listings** (Stripe boost/featured placement).
+- **Ticketing:** sell or affiliate-link tickets; RSVP → paid.
+- **Ads / sponsorships:** clearly-labeled city-sponsor + sponsored travel-guide slots.
+- **Optional:** PPV / tip-the-team on live; pro promoter/team subscriptions (analytics, scheduling).
+
+**Gating summary:** Phase 0 is the only phase with hard schedule pressure. Phase 1 is mostly data + admin work. Phase 2 is the single biggest engineering investment, correctly placed after product-market signal. Phase 3 is additive flags + payment rails, no re-architecture.
+
+---
+
+## Media, Live Streaming & Moderation
+
+### Design principles
+- **Buy the hard, undifferentiated parts** (transcode ladders, edge delivery, low-latency live) from Mux; spend Zion's time on the community product.
+- **Provider-agnostic seam:** all media touches go through `MediaService`; all trust-and-safety through `ModerationService`.
+- **Moderation is a property of the pipeline, not a bolt-on.** Every piece of user media flows through one state machine; later roles/verification just change *who* fast-paths through it.
+- **Cost scales with usage, not ambition** - no always-on encode fleet.
+
+### Stored video (upload → transcode → playback)
+1. Client → `POST /api/media/uploads` `{ kind, context }`.
+2. API authenticates, runs **pre-upload gating** (rate limit + trust check), creates `media_asset` in `pending_upload`, asks Mux for a **one-time direct upload URL**. Browser uploads straight to Mux - bytes never transit our backend.
+3. Mux transcodes async → webhook `video.asset.ready` to `POST /api/media/webhooks/provider`. We verify signature, move to `transcoded`, kick off the moderation pre-check.
+4. On pass → `published`, visible in feeds.
+
+**State machine (single source of truth for transcode + moderation):**
+```
+pending_upload → uploaded → transcoded → in_review → published
+ ├──→ rejected
+ └──→ taken_down (post-publish, reversible/logged)
+```
+**Playback:** LL-HLS / HLS over Mux CDN, `hls.js` on web (native on Safari/iOS). Use **signed playback tokens from day one** (free now; ready for gated/ticketed content). Auto poster + storyboard hover-scrub. Let Mux auto-generate the ladder (240→1080p, H.264); store vertical phone video as-is and let the player letterbox. **Only metadata + provider IDs live in Postgres.**
+
+### Live streaming
+- **Provider: Mux Live** (same vendor/integration as VOD; one bill). RTMP(S)/SRT ingest + WHIP (WebRTC) browser ingest → LL-HLS out (3-8s) → auto-record to VOD.
+- **Pro path:** issued RTMP/SRT stream key into OBS / venue encoder.
+- **Casual path:** in-app "Go Live" → phone camera via WHIP → Mux down-transcodes to the same HLS ladder. This is the open-contribution path.
+- **Flow:** `POST /api/live/streams` → trust/rate gate → Mux creates a Live Input → store `live_stream` (`status=idle`). Broadcaster connects → webhook → `live` → appears in "Live now" rail. Viewers play LL-HLS; chat over Supabase Realtime keyed to the stream id. Disconnect → `ended`; auto-VOD enters the **same** `transcoded → in_review → published` path (live content is **not** exempt from moderation - reviewed post-hoc, takedownable).
+- **Live moderation reality:** you cannot pre-review a live broadcast. So: (a) gate *who can start* via trust/rate limits, (b) prominent report button → past a low distinct-reporter threshold, **auto-pause** the input pending review, (c) auto-record everything so there's always an artifact, (d) a `POST /api/live/streams/:id/terminate` kill-switch the operator can hit from any device.
+
+### Minimum-viable trust & safety (makes "anyone can post" survivable)
+- **Identity floor:** account required to post (email/Google/Apple); anonymous read OK, anonymous write never. Per-account **trust score** (rises with age + clean history + verified email/phone) gates rate limits and auto-publish-vs-queue - this is exactly where verification badges plug in later (verified = higher tier).
+- **Rate limits (primary spam defense):** posts per account per hour/day scaled by trust tier (new accounts tight: ~3 videos/day, 20 comments/hr); per-IP/device caps; upload size/duration caps (~10 min) to bound transcode cost; exponential backoff on repeat trips.
+- **Automated pre-filters:** **text** via OpenAI Moderation (free) + slur denylist; **visual** via Hive/Rekognition scoring each transcoded asset before it leaves `in_review`; spam/link heuristics + perceptual-hash dedup.
+- **Community reporting:** report action on every video/comment/event/stream; distinct-reporter-weighted thresholds → auto-hide (soft takedown) / auto-pause live; false-reporters lose report weight.
+- **Operator console:** one mobile-friendly **moderation queue** (auto-flagged + reported-by-weight + `in_review`); actions = approve / reject / take down / warn / suspend / ban / terminate live; every action writes **AuditLog**; takedown is a reversible state, not a hard delete.
+- **Legal baseline (not optional even in MVP):** designated **DMCA** agent + takedown endpoint (step footage carries copyrighted music); **CSAM** hits are auto-block + preserve + report to NCMEC (legal obligation); ToS + Community Guidelines scoped to "Divine Nine / NPHC stepping" so off-topic has a stated removal basis; age gate at registration.
+- **Deferred-but-designed roles:** `trust_tier` + `role` exist from day one (default `member`). Verification later = a manual/document-backed tier bump that grants auto-publish, a badge, and optional self-moderation of one's own event comments. No pipeline rework.
+
+### Provider-agnostic interfaces
+```
+MediaService: createDirectUpload(ownerId, ctx) · getAsset(assetId) ·
+ createLiveStream(ownerId, eventId) · terminateLiveStream(streamId) ·
+ handleWebhook(payload, sig) · signPlaybackToken(assetId, ttl)
+ModerationService: checkText(text) · checkAsset(assetId) ·
+ recordReport(targetType, targetId, userId, reason) · evaluateThresholds(targetId)
+```
+Mux today; Cloudflare Stream / Bunny / self-hosted homelab MediaMTX+FFmpeg tomorrow = one new adapter, no feed/queue/console changes.
+
+---
+
+## UX & Key Screens
+
+### Principles
+- **City-first, not feed-first.** A persistent city picker in the top nav scopes everything below it. Stepping is hyper-local and event-driven.
+- **Discovery over creation.** Home + city pages are browse surfaces; posting is one tap away but never in the way.
+- **Live is a first-class destination** - a red LIVE badge in the nav + a hero slot on Home when a show is streaming in the selected city.
+- **Open posting, role-ready.** Every post is attributed; `role`/`verified` render as badges now, gate features later.
+- **PWA-shaped:** bottom tab bar on mobile, left rail on desktop, same routes so native wrappers reuse the shell.
+
+### Primary navigation
+- **Global top bar:** Logo · **City picker** · Search · LIVE badge (only when streaming in the selected city) · Profile/Post.
+- **5 primary destinations** (bottom tabs / left rail): **Home** · **Events** · **Watch** (videos + live) · **City** (per-city hub) · **Post (+)**. Account lives behind the top-bar avatar.
+
+### Screens
+- **Home** - hero = a current LIVE stream *or* the next big event in the selected city; rails: Live now, This weekend in {City}, Trending clips, New step shows, Featured promoters, Switch city. Defaults to Atlanta, surfaces the picker if no city chosen.
+- **Events List** - filter by city, date range, type. Card: flyer, title, date/time, venue, host/promoter, free/price badge, Going count.
+- **Event Detail** - flyer, description, date/time, venue + map + directions, host/promoter link, lineup (teams, DJs), ticket/RSVP (external now), "Watch live" CTA if streamed, comments, **"Plan your trip"** deep-link into the City Guide pre-filtered to that venue.
+- **Watch Home** - tabs **Live** (current/scheduled streams + viewer counts) and **Videos** (grid of recaps/performances/tutorials, filter by city + tag).
+- **Live Stream** - player, title, host, viewer count, **live chat** rail, "going to next event" CTA, share.
+- **Video Detail** - player, title, uploader (badge), city/tags, description, comments, related clips.
+- **City Home** - city header + hero, quick tiles to sub-sections, "Happening in {City}" strip, map of step locations.
+- **City sub-sections** - Step Teams & Chapters (→ profile), Classes & Workshops (→ detail), DJs (→ profile), Promoters (→ profile), Step Locations/Map, and the **City Travel Guide** (Hotels · Restaurants · Malls · Attractions/Entertainment · Hospitals, each with distance-to-venue when arrived via an event, map pins).
+- **Post (sheet)** - pick a type (Event · Video · Live · Class · DJ · Promoter · Team/Chapter · Guide tip); minimal type-specific form; city pre-filled from the active picker.
+- **Profile / Search / Auth** - my posts, saved/going, follows, uploads/streams (role badge slot reserved); unified search across all entities; lightweight sign-up (email + Google + Apple).
+
+### Routing
+`/` · `/events` · `/events/:id` · `/watch` · `/watch/live/:id` · `/watch/video/:id` · `/city/:city` · `/city/:city/{teams|classes|djs|promoters|map|guide}` · `/post` · `/u/:user` · `/search`. City is a route param + persisted in the picker.
+
+### Build notes
+- **One reusable card component** (image, title, meta row, action) with typed variants covers events, videos, profiles, guide entries.
+- **Badge/role system is purely presentational at MVP** - reads optional `role`/`verified`; turning on moderation later = populating fields + adding the review queue, not restructuring screens.
+- Travel guide ships **curated** (hand-seed the launch cities), Google Places as optional enrichment, kept steppin-relevant by anchoring entries to event venues.
+
+---
+
+## City Launch Plan
+
+City rollout is data-entry-bound, not engineering-bound - `City.is_launched` and `City.tier` gate visibility, so launching a city = seeding its rows and flipping a flag.
+
+| Wave | Cities | `tier` | Role | Seed checklist before flipping `is_launched` |
+|---|---|---|---|---|
+| **1 (Phase 0)** | **Atlanta**, **Washington DC** (DMV) | 1 | Primary launch markets | 5-10 real upcoming events; venues geocoded; 3-5 promoters; known DJs/teams/chapters; embedded video links |
+| **2 (Phase 1)** | **Houston**, **NC Triangle (Greensboro/Durham)** | 2 | First expansion | Same seed bar + curated travel guide rows (hotels/restaurants/malls/attractions/hospitals near key venues) |
+| **Pilot** | **Memphis** (home market) | 3 | Home pilot - dogfood + relationships | Lowest-risk testbed; Zion's local network seeds events/teams; used to validate moderation + posting UX with friendly users |
+
+- **DMV / Triangle grouping** is handled by the `metro_label` string (no `Metro` table yet): DC = "DMV", Greensboro/Durham = "NC Triangle".
+- **Per-city seeding is the launch gate, not code.** Each new city needs: real upcoming events, geocoded venues, a handful of promoters/DJs/teams/chapters, and (Phase 1+) curated travel-guide rows anchored to its main venues.
+- **Scheduled city-data ingest** (Google Places → hotels/restaurants/malls/hospitals/attractions) runs as the homelab cron job (CT 215), caching results into the `TravelPlace` table to control API spend; manual curation always overrides.
+- **Memphis-first dogfooding:** even though Memphis is `tier 3` for public launch, use it earliest internally to exercise open-posting and moderation with a friendly local crowd before ATL/DC go fully public.
+
+---
+
+## Build Checklist (first sprint - start the web MVP)
+
+Ordered, concrete. Goal of sprint 1: a deployed, installable PWA where you can post and browse Atlanta + DC events with comments.
+
+1. **Repo + monorepo scaffold.** `create-turbo`; `apps/web` (Next.js 15 App Router + TS + Tailwind + shadcn/ui); `packages/{db,api,types,config,ui}`. Add `next-pwa` (manifest + service worker).
+2. **Supabase project.** Provision Postgres + Auth + Storage; enable **PostGIS**. Wire env into Vercel + local `.env`.
+3. **Drizzle schema (in `packages/db`).** Implement the Ownership + Moderatable mixins, then **City, User, Organization, Venue, Event, Promoter, DJ, StepTeam, Chapter, Class, Video, Comment, Flag** + the joins (EventParticipant, EventDJ, ChapterMembership). Include the dormant `role`/`trust_tier`/`status`/`verified_at` columns now. Generate + run migrations.
+4. **RLS policies (allow-all today, role-shaped).** Authenticated users get write on their own rows; reads are public where `status='active'`. Policies reference `role`/`status` so tightening later is a policy edit.
+5. **Seed reference data.** Divine Nine Organizations; City rows for Atlanta + DC (`is_launched=true`, `tier=1`, `metro_label`); a few real Venues geocoded.
+6. **Auth.** Supabase Auth with email magic-link + Google + Apple; session → tRPC context; lightweight sign-up screen.
+7. **tRPC layer (in `packages/api`).** Routers: `city`, `event`, `promoter`, `dj`, `stepTeam`, `chapter`, `class`, `video`, `comment`, `flag`. Zod input schemas shared. CRUD with optimistic publish (`status='active'` on create) + ownership stamping.
+8. **City picker + scoping.** Top-bar picker locked to Atlanta + DC; route param `/city/:city`; every list query filters `city_id` + `status='active'`.
+9. **Events surface.** SSR Events List (filter by type + date) and Event Detail (Google Maps deep-link, share link) as Server Components.
+10. **Open posting forms.** Post sheet with type picker → minimal forms for Event, Video (embed URL), Promoter/DJ/Team/Class; city pre-filled.
+11. **Videos (embed-only).** Render YouTube/IG/TikTok embeds with title + city tag; Watch grid.
+12. **Comments.** Polymorphic comment component under Event + Video; create/list via tRPC.
+13. **Directories.** Flat list pages for Promoters, DJs, Step teams/chapters, Classes (reuse the one card component, typed variants).
+14. **Minimal moderation.** Admin-only view of recent posts; soft-hide (`status='hidden'`) action; "report" → creates a Flag; AuditLog write on hide.
+15. **Stub the seams.** Define `MediaService` + `ModerationService` interfaces in `packages/api` (no Mux/Hive wiring yet) so Phase 2 is an adapter, not a refactor.
+16. **PWA polish + deploy.** Manifest, icons, offline shell, mobile-first responsive pass. Deploy `apps/web` to Vercel; homelab CT 215 as staging. Smoke-test install-to-home-screen on a real phone.
+
+---
+
+## Risks & Open Decisions
+
+| Item | Status | Resolution / mitigation |
+|---|---|---|
+| **Mux per-minute cost at scale** | Accepted pre-monetization | Zero idle cost; `MediaService` seam → swap to Cloudflare Stream / homelab the moment delivery cost dominates (post-ticketing). |
+| **Two view layers (Next web + Expo native)** | Decided tradeoff | Share `packages/` logic, fork the shell. Worth it for SSR/SEO on discovery pages - the way a city events directory actually gets found. |
+| **Open posting → spam/abuse with no human pre-review** | Designed-for | Identity floor + trust-tiered rate limits + free text moderation + per-asset visual moderation + report thresholds + a mobile operator queue. "Survivable, not perfect." |
+| **Live moderation can't be pre-reviewed** | Designed-for | Gate who can go live, report-threshold auto-pause, auto-record everything, operator kill-switch. |
+| **Legal: DMCA (copyrighted music in step footage) + CSAM** | Must ship in MVP | Designated DMCA agent + takedown endpoint; CSAM = auto-block + preserve + NCMEC report; ToS/guidelines + age gate at signup. |
+| **City seeding is the real launch bottleneck** | Operational, not technical | Each city needs hand-seeded events/venues/profiles before `is_launched`; Memphis pilot dogfoods the flow first. |
+| **Generic listing vs typed entities** | Resolved → typed | Keep explicit typed entities for query clarity + relational integrity; the shared mixins deliver the "one content model" uniformity without a blob table. |
+| **Monetization model undetermined** | Deferred, pre-wired | `ticket_url`/`price_text`/`is_free` + `owner_id` exist; Stripe + Listing/Order/Subscription tables slot into tRPC in Phase 3. No paywall in MVP. |
+| **Google Places API spend** | Controlled | Cache aggressively into `TravelPlace`; manual curation overrides; scheduled homelab ingest, not live per-request calls. |
+| **Live chat scale (Comment vs dedicated table)** | Watch item | Start on Supabase Realtime + capped chat tail; split to a dedicated `LiveChatMessage` table only if volume demands. |
+| **Apple sign-in dependency for future iOS** | Pre-empted | Apple OAuth enabled in Supabase Auth from day one so the Phase 2 App Store build has no auth blocker. |
+| **Production must survive homelab reboots** | Decided | All user-facing infra in cloud (Vercel + Supabase + Mux); homelab is staging, scheduled jobs, backups, and documented fallback only. |