| | @@ -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. | |