PROJECT_SCOPE.md 138 KB

Project: MovieDice

1. Project Overview

MovieDice is a mobile-first web app that helps friend groups collaboratively build a shared movie watchlist and — when nobody can agree on what to watch — randomly select one using an animated "Roll the Dice" mechanic. Groups join via a short invite code, add movies from a live TMDB search, and mark films as watched together. The app removes the friction of the "what should we watch?" problem by making both curation and random selection fast and fun.

A public landing page lets visitors try the dice mechanic against the TMDB database before signing up, lowering the barrier to entry. Both the landing page and in-app rolls use the same horizontal poster-carousel mechanic: a strip of posters spins and snaps so a gap lands at viewport center, and the result card emerges with an animate-emerge pop-in (scale 0 → 1.08 → 1, ~500ms, posters spread ±110px on settle).

2. Target Users

Primary: Friend groups, couples, roommates, or families who watch movies together and maintain an informal "we should watch this" list that currently lives in text threads or notes apps.

Secondary: Remote groups (long-distance friends, online communities) who watch movies synchronously or asynchronously and want a shared queue.

Key traits:

  • Mobile-first users — majority of interaction happens on phones
  • Low tolerance for signup friction — will abandon if auth is annoying
  • Motivated by the social/playful aspect as much as the utility

3. Core Value Proposition

One shared list. One button to decide. No arguments.

MovieDice solves group decision paralysis by combining collaborative curation (everyone adds what they want to watch) with a delightful randomizer that removes the burden of choosing. The invite-code group model means zero signup friction while still keeping lists private to the group.

4. Scope of Work

In Scope (MVP)

Feature Description Priority
Landing page Centered logo, splash text, slot-machine reel animation on Roll the Dice (3 reels spinning through ~20 automatically fetched posters from TMDB popular/top-rated, replaced on each periodic refresh), Genre Roll against TMDB (no login required), Login button, scrolling About section, 3-step how-it-works demo with alternating left-right-left alignment, TMDB attribution footer, privacy policy link, legal footer (© MovieDice, privacy links, CCPA/GDPR anchors, cookie notice). Roll result card emerges in the carousel center and stays settled until next user action. Result card has an "i" info button that opens a MoreInfoModal (plot, Add to list → /login, Watch Trailer). Must Have
Anonymous auth via Supabase User picks a display name and optional avatar color; account created via supabase.auth.signInAnonymously() which issues a JWT for RLS-compatible sessions; persisted on device via @supabase/ssr cookie-based session handling Must Have
Recovery code A 24-character alphanumeric code (128-bit entropy) shown once after account creation that lets users reclaim their identity on a new device; hashed with Argon2id (memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes) before storage; single-use (invalidated after successful claim); claim endpoint rate-limited (5 failed attempts per IP per 15-minute window) Must Have
Group creation with invite code Creator gets a short human-readable code in WORD-WORD format (e.g., WOLF-MOON; word list: 2,000+ words, 3-8 characters each, offensive/confusing terms filtered, uppercase display, case-insensitive comparison, collision check on generation) to share; creator becomes List Admin Must Have
Group join via invite code Enter code to join a group and access its shared list; regular member role assigned; join endpoint rate-limited (5-10 failed attempts per IP per 15-minute window); group join is a server-side operation via service role key (not client-side INSERT) Must Have
List Admin permissions Creator can rename the list, initiate list deletion or ownership transfer (on self-removal), remove members, and regenerate the invite code Must Have
Regular user permissions Members can add/remove movies, mark movies as watched, and leave the list Must Have
Movie search (TMDB integration) Search bar queries TMDB via server-side API proxy (/api/tmdb/*) with debounce (~300ms); all calls set include_adult=false and apply PG-13 certification filter (see adult content policy below); results show below a separator from in-list results. Search bar styled max-w-xl, centered text, bold "ADD A MOVIE" placeholder, white pulsating glow (@keyframes pulse-glow in globals.css, prefers-reduced-motion honored). Must Have
Add/remove movie Tap a TMDB result to add it; poster, genres, title, year, and trailer URL auto-populate (trailer URL stored in DB and refreshed periodically); added-by attribution stored Must Have
Poster-forward grid view 2-column evenly-scaling grid; each card (PosterCard, now a full-card button) shows movie poster (full bleed, TMDB native sized URLs) with title below and meaningful alt text; added-by avatar overlaid top-right; green-circle checkmark overlaid top-left when watched; tapping a card opens ListMoreInfoModal; infinite scroll loading 12 movies initially. RollBar hidden when allMovies.length === 0. Must Have
Movie info modal Tapping a poster opens ListMoreInfoModal (portal to document.body). Contents: poster (w500 with alt text) + title + "Added by [username]" + genre tags + Watched It button + Trailer button + Delete button (only when caller supplies onDelete prop — roll-result modal omits delete). Watched It: three-state idle→confirming→committed with shake-to-arm and localWatched display; reverse path mirrors with gray "Unwatch Movie" intermediate. Delete: red idle → "Click to confirm" → mutation + close; both armed states apply animate-shake with 4s auto-disarm; arming one disarms the other. Trailer opens stored URL in new tab. Modal uses createPortal to escape transformed ancestors; ESC closes, click-outside closes, focus trap, focus restoration on close. TMDB overview fetched per-open (no DB column; TanStack Query caches within session). Must Have
Genre filter Genre tags render in ListMoreInfoModal and on result cards — informational only, not tappable filters. Genre Roll is the sole filter entry point. Deliberate scope decision (2026-05-06): tap-tag filtering was removed when ExpandedPanel was replaced by the modal; selectedGenre wiring on PosterGrid is dormant and being removed. Must Have
Roll the Dice Button pinned above the movie grid (list view) or in RollSection (home); triggers the carousel roll animation; announce result via aria-live="polite" region; result card shows gold glow (ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)]). ListTeaserCard in ListRollCarousel is a full <button> — entire card is the click target, opens ListMoreInfoModal. The small "i" glyph is decorative (aria-hidden). Must Have
Re-roll Tapping Roll again re-rolls from the same eligible pool Must Have
Genre + Emotion Roll Secondary button accepting comma-separated genres and/or emotion keywords; maps emotions to genre IDs, filters pool, then rolls Must Have
Watched state (per group, toggle) Marking a movie watched moves it to a collapsed "Watched" section; marking again moves it back. Green-circle checkmark overlay and button color update in real time across all members. Announce state change via aria-live="polite" region. Must Have
Real-time list sync Add, remove, and watched-status changes appear live on all connected group members' screens (Supabase real-time); subscribe only to the currently-viewed list, unsubscribe on navigation away Must Have
Logged-in home page Upon login or return visit (stored user ID detected), user lands on a home page that mirrors the landing page layout but shows their lists as a single-column centered grid. Button hierarchy (locked 2026-05-21): top create/join row is hidden when user has no lists; centered stacked large CTAs (Create List, Join a List) are the empty-state path and shown only when useUserGroups returns empty; RollSection (Roll + Genre Roll + Create List) only renders when both groups and movies exist. Roll the Dice and Genre Roll roll across all user lists combined and display the result using ListRollCarousel on the home page (no navigation into a specific list). Roll buttons styled to match RollBar (red random / purple genre, min-h-[44px]). JoinListButton accepts size="large" prop. Must Have
Multi-group support A user can belong to more than one group; all their lists appear as cards on the home page Should Have
Invite code rotation List Admin can regenerate the invite code to revoke access for anyone with the old code Should Have
Trailer URL periodic refresh Background job via Node.js cron container on a bi-weekly cadence refreshes stored trailer URLs only for movies where trailer_url is currently null. Note: this behavior should be reassessed post-launch to also refresh stale URLs after a certain age. Should Have
Master Admin panel Site-owner-only admin page with TOTP 2FA; can search and delete any list or user (deletion must remove both public.users row and auth.users record via supabase.auth.admin.deleteUser()); credentials set via environment variables; session managed via iron-session v8 (HttpOnly, Secure, SameSite=Strict cookie, 8-hour expiry) Must Have
Adult content policy Content policy = PG-13-or-better via TMDB certification allowlist (not just adult:false field). Belt-and-suspenders approach (locked 2026-05-21): /discover, /popular, /reel-posters use certification.lte=PG-13 (US) via DISCOVER_CERT_PARAMS; /search post-filters keyword results through fetchAndFilterByCert (concurrency-capped detail fetches with append_to_response=release_dates); /movie/[id] 404s any title that fails isMovieAllowedByCert. Allowlist covers US/GB/DE/FR/AU/CA/NL/ES/IT/JP/KR/BR/MX/IE/SE. STRICT mode: titles with no recognized country cert are rejected. Implementation: src/lib/tmdb/certification.ts. Exception (locked 2026-05-21): PINNED_REEL_POSTERS entries (e.g., tmdb_id 615 — The Passion of the Christ, R-rated US) are a sanctioned bypass of this filter for the landing carousel editorial surface only. Pins are intentional content curation; each addition is a deliberate policy override. See Section 9b for full rationale and implications. Must Have
Legal footer src/components/shared/legal-footer.tsx, composed inside TMDBFooter. Links to /privacy, anchors for CCPA (/privacy#ccpa) and GDPR (/privacy#gdpr), cookie notice ("essential cookies for authentication"), © {year} MovieDice. Deliberately omits email/phone storage — footer describes ACTUAL data practice only, lowest legal risk (locked decision 2026-05-21). /terms route does not exist; Terms link omitted — do not create a stub until terms content is ready. Must Have

Out of Scope (Future)

  • Compact list/grid toggle — user unfamiliar with it at scoping; deferred post-MVP
  • Per-user watched status — MVP uses per-group status only
  • Push notifications — adds native app complexity
  • In-app chat or comments on movies — scope creep risk
  • Rating or ranking movies — adds complexity to the watched flow
  • Native iOS/Android apps — PWA covers MVP use case
  • Social discovery (public lists, find groups) — contradicts the private group model
  • Streaming availability data (where to watch)
  • Movie recommendations engine — randomizer covers this need for MVP
  • /terms page — defer until terms content is authored; then add route and LegalFooter link

5. Feature Flows

Landing Page (Pre-Login)

1. Visitor lands on the root URL — no login required
   NOTE: src/app/page.tsx was a duplicate stub that shadowed (public)/page.tsx; removed (2026-05-02).
   The canonical landing is src/app/(public)/page.tsx; TMDBFooter (with LegalFooter composed inside)
   is mounted in (public)/layout.tsx.
2. Centered "Movie Dice" header/logo displayed
3. Splash text describes the site briefly (1-2 sentences)
4. "Roll the Dice" button is visible — tapping it triggers the slot-machine reel animation:
   a. Three side-by-side reels spin through poster images sourced from the landing_reel_posters table
      (populated by seed.sql; cron refresh is a pending TODO — see 5.2).
      The /api/tmdb/reel-posters route currently fetches TMDB live on each request and ignores the
      table; PINNED_REEL_POSTERS constant always prepends hard-coded entries (e.g., tmdb_id 615).
      Reel posters use aria-hidden (decorative during animation).
      TWO DISTINCT POOLS — do not conflate:
        - Carousel/reel posters: landing_reel_posters table + PINNED_REEL_POSTERS fallback (curated)
        - Roll the Dice result: live /api/tmdb/popular?page=N, random page 1–50 (~1000-movie pool)
   b. Reels decelerate and snap so the midpoint of a gap between two posters lands at viewport center.
      Result is a single movie drawn from /api/tmdb/popular?page=N (random page 1–50; pool ~1000 movies).
      The result is NOT constrained to the reel poster set.
      KNOWN BUG (U9): modulo wrap missing in spinning branch causes scroll offset to drift on
      repeated rolls; fix tracked in research/PHASE5_UI_FIXES_PLAN.md U9.
   c. Result TeaserCard (~1.3x reel poster size) emerges with an animate-emerge keyframe
      (scale 0 → 1.08 → 1, ~500ms, bouncy easing) in the carousel center — posters spread ±110px
      at viewport center on settle. Card remains in settled state until the user re-rolls or refreshes
      (no auto-resume timer). Card shows poster (with alt text), title, genres, and an "i" info button.
      Gold glow applied to result card: ring-2 ring-yellow-400/70 shadow-[0_0_32px_rgba(250,204,21,0.55)].
      Respects prefers-reduced-motion: animation skipped, card appears instantly.
   d. Tapping "i" opens <MoreInfoModal> (src/components/landing/more-info-modal.tsx):
      - Side-by-side layout at sm+ (poster left, content right), stacks on mobile, max-w-5xl/lg:max-w-6xl
      - Shows: title + year, genres, plot (movie.overview)
      - "Add to list" button → navigates to /login (no save-intent flow; user adds manually post-auth)
      - "Watch Trailer" button → lazy-fetches /api/tmdb/movie/[id]/videos, opens trailer
      - a11y: ESC closes, click-outside closes, focus trap, focus restoration on close
      - Uses createPortal to document.body to escape the animate-emerge transformed ancestor
   e. Animation is user-triggered only and completes within 5 seconds (WCAG 2.2.2)
5. "Genre Roll" button visible — accepts comma-separated genres/emotions, no reel animation;
   result displayed as a static teaser card (poster with alt text, title, genres).
   No link, no tap action.
   On mobile: Roll + Genre buttons stack full-width (max-w-xs flex-col); desktop unchanged.
6. "Login / Get Started" button below the roll buttons
7. User scrolls down to reveal:
   a. About section — fuller description of how MovieDice works
   b. 3-step how-it-works demo (Create a list → Add movies → Roll the dice)
      - Step 1: left-aligned
      - Step 2: right-aligned
      - Step 3: left-aligned (zigzag visual rhythm)
8. Footer on all pages (mounted in segment layouts): TMDBFooter (TMDB logo + link + disclaimer)
   with LegalFooter composed inside (© MovieDice, /privacy link, CCPA/GDPR anchors, cookie notice).
   TMDBFooter now provided by (app), (auth), admin, and (public) layouts — inline duplicate removed
   from /list/[id] (2026-05-21).

Onboarding (New User)

1. User taps "Login / Get Started" on landing page
2. User enters display name, optionally picks an avatar color
3. Account created via Supabase Anonymous Sign-In (supabase.auth.signInAnonymously());
   JWT issued and managed by Supabase GoTrue; session persisted via @supabase/ssr
   cookie-based handling (createBrowserClient / createServerClient)
4. Recovery code (24 alphanumeric characters, 128-bit entropy) shown once — user prompted to save it
5. User selects: "Create a Group" or "Join with a Code"
   A. Create → enter group name → group created → creator assigned List Admin role
      → router.push("/list/{id}") directly into the new list view (no in-form invite-code
        panel after creation; join code is visible in the list header under the list name)
   B. Join → enter invite code → validated (rate-limited, server-side via service role key)
      → member role assigned → lands on home page (with the joined list card shown)

Logged-In Home Page (Returning or Newly Onboarded User)

1. App checks for valid Supabase Auth session
2. If valid session found → navigate directly to the home page (skip landing page)
3. If no session → show landing page (pre-login)
4. Home page layout mirrors the landing page layout, with these differences:
   a. Button hierarchy (locked 2026-05-21):
      - Top create/join row: HIDDEN when user has no lists
      - Centered stacked large CTAs (Create List + Join a List, size="large"): shown ONLY when
        useUserGroups returns empty; this is the primary empty-state path for new users
      - RollSection (Roll + Genre Roll + Create List): renders ONLY when both groups AND movies exist
      - EmptyState component does NOT render duplicate Create/Join buttons
      - Roll buttons styled to match RollBar (red random / purple genre, min-h-[44px])
   b. The About / how-it-works section is replaced by the user's list cards
5. List grid: mx-auto flex max-w-md flex-col gap-4 — single column always, centered
6. Each list card shows:
   - List name (left-aligned)
   - Number of movies with a film emoji (right-aligned)
   - Below: "Created by: [username]"
7. Tapping a list card navigates into that list view
8. "Roll the Dice" and "Genre Roll" buttons roll across ALL unwatched movies from ALL of the
   user's lists combined (cross-list roll); home roll uses ListRollCarousel (same component as
   list view); cross-list watched toggle done via inline fetch + manual queryClient.invalidateQueries
   (per-movie group_id)
9. Page header text is centered on mobile
10. If no session (new device) → prompt for recovery code or start fresh

Adding a Movie

1. User taps search bar at top of list view
2. User types a movie title; TMDB is queried via server-side proxy (/api/tmdb/search)
   with ~300ms debounce; include_adult=false on all calls; results post-filtered by
   fetchAndFilterByCert (PG-13 certification allowlist)
3. Results appear in two sections:
   - Top: movies already in the group's list (labeled "In Your List")
   - Below separator: TMDB search results (cert-filtered)
4. User taps a TMDB result → movie inserted into DB with poster, genres, title, year,
   trailer URL (fetched from TMDB via server proxy at add-time;
   validated against allowlist: youtube.com, themoviedb.org, imdb.com),
   and added-by attribution
5. All group members see the new movie appear in real time

Movie Card Grid View

1. Default view: 2-column evenly-scaling poster grid (3-4 columns on tablet/desktop)
2. Each card (PosterCard — full-card button, no inline ExpandedPanel):
   - Movie poster (full bleed, TMDB native sized URL: w342) with meaningful alt text
   - Movie title below the poster
   - Added-by user avatar overlaid top-right corner
   - Green-circle checkmark overlaid top-left — only when movie is watched (replaces binoculars emoji)
   - Decorative "i" glyph bottom-right (pointer-events-none, opens nothing — info is in modal)
3. Grid loads 12 movies initially; additional movies load automatically on scroll to bottom
4. Tapping a card opens ListMoreInfoModal (portal)
5. All poster images use native loading="lazy" attribute
6. RollBar hidden when allMovies.length === 0 (movie-list-client.tsx)

Movie Info Modal

SHIPPED (2026-05-06): src/components/dice/list-more-info-modal.tsx
Replaces the former ExpandedPanel inline expansion. Always renders as a portal to document.body.

1. User taps any movie poster in the grid (or taps the result card after a list/home roll)
2. ListMoreInfoModal opens:
   a. Poster (w500 with alt text)
   b. Title
   c. "Added by [username]" (when addedByName prop supplied)
   d. Genre tags
   e. TMDB overview fetched per-open via /api/tmdb/movie/[id] (no DB column; cached in TanStack Query
      within session only — no persistent caching)
   f. "Watched It" button — three-state with shake-to-arm:
      - Idle (unwatched): gray "Watched It"
      - Armed: shaking green "Click to confirm"
      - Committed: static green "Watched" (uses localWatched for display correctness mid-mutation)
      - Reverse path: idle (watched) → gray "Unwatch Movie" armed → back to unwatched
   g. "Trailer" button — opens stored trailer_url in new tab (rel="noopener noreferrer")
   h. "Delete Movie" button (only when onDelete prop supplied; list view supplies it; roll-result
      view omits it to prevent accidental deletes):
      - Idle: red "Delete Movie"
      - Armed: shaking "Click to confirm"
      - Confirmed: mutation fires + modal closes
   i. Arming one action (watched/delete) auto-disarms the other. 4s auto-disarm timer on both.
3. a11y: ESC closes, click-outside closes, focus trap, focus restoration on close
4. NOTE: movies.overview is NOT stored in the DB. Modal fetches from TMDB on each open.
   Follow-up: consider caching in TanStack Query with a reasonable staleTime.

Rolling the Dice (In-App)

SHIPPED (2026-05-06): src/components/dice/list-roll-carousel.tsx
Used on both the list page (via RollBar above the search bar) and the home page (via RollSection).

1. User taps "Roll the Dice!" button
2. ListRollCarousel plays:
   - Pool tiles to ≥10 posters by duplicating when list is short
   - ~500ms dice-emerge pop-in entrance
   - 100ms pause
   - 2750ms easeOutCubic spin
   - Snap to gap-at-center
   - Posters spread ±110px
   - Result card emerges with animate-emerge (gold glow: ring-2 ring-yellow-400/70
     shadow-[0_0_32px_rgba(250,204,21,0.55)])
   - Respects prefers-reduced-motion
3. Pool:
   - List page: that list's unwatched movies
   - Home page: all unwatched movies across all user lists combined
4. On the home page, result stays in place — user is NOT navigated into any list
5. Result announced via aria-live="polite" region for screen readers
   NOTE: RollAnnouncer fires "complete" at useRoll's 2500ms mark while carousel spins
   until ~3350ms — minor a11y timing mismatch, not blocking
6. Tapping the result card (ListTeaserCard — full <button>) opens ListMoreInfoModal;
   the decorative "i" glyph on the card is aria-hidden
7. Tapping Roll again re-rolls from the same eligible pool
8. Tapping "Genre Roll!" opens a text input
9. User enters genres and/or emotions (e.g., "action, excited")
10. App normalizes input, maps emotion keywords to TMDB genre IDs, filters the unwatched pool
11. Same carousel animation plays on filtered results
12. If no matches found: "No matches — showing full list" shown, roll proceeds unfiltered

Button layout:
- List view: RollBar moved ABOVE search bar; Roll + Genre buttons are flex-col items-stretch
  w-full on mobile, sm:flex-row sm:flex-1 on desktop; RollBar hidden when movie list is empty
- Home: Roll + Genre + Create List grouped in RollSection; flex-col items-stretch w-full on
  mobile, sm:flex-row sm:justify-center on desktop; RollSection only renders when groups AND
  movies both exist

List Page Header

SHIPPED (2026-05-06, updated 2026-05-21): src/app/(app)/list/[id]/page.tsx
- 3-column grid layout: back arrow (→ /home) left, center block, settings cog right
- Center block: list name (text-2xl sm:text-3xl font-bold), "Invite Friends to Add Movies!"
  eyebrow above the invite code (2026-05-21), invite code displayed as large
  text-3xl sm:text-4xl font-mono tracking-widest (replaces old small mono chip)
- /create-group page mirrors back-arrow + centered-title pattern; button text changed to
  "Create List" and centered (2026-05-06)

List Admin Actions

NOTE (2026-05-06): Settings page at src/app/(app)/list/[id]/settings/page.tsx is now live (gear icon was previously broken/404). The page mounts src/components/groups/settings-panel.tsx. Join code is displayed in the list header (src/app/(app)/list/[id]/page.tsx) under the list name as a large mono invite code — visible to all members on the list view, no need to navigate to settings to find it.

1. List Admin taps the gear icon → navigates to /list/[id]/settings (settings page now exists)
2. Available actions (settings cleanup shipped 2026-05-21):
   - Rename the list ("Rename List" section heading; flex-col gap-2 sm:flex-row form, "Save name" button)
   - Delete the list (see deletion flow below)
   - View member list with option to remove individual members
   - Regenerate invite code (inline text action, aria-label preserved)
   - Copy invite code (inline text action, aria-label preserved)
   NOTE: Invite-code section heading and display chip removed from settings; the header provides it.
3. Regular members see a settings menu with only: "Leave this list" option
   (same shake-to-arm confirmation pattern as admin terminal actions)
4. Settings page header: 3-col grid, SVG back arrow + centered "List Settings" title

Admin Self-Removal / Ownership Transfer flow:

   a. List Admin taps "Leave this list" (or equivalent self-removal action)
   b. Shake-to-arm pattern (uses animate-shake utility, globals.css):
      - First click: button shakes and enters armed state; 4-second auto-disarm timer starts
      - Second click within 4s: confirms the action
      - Clicking elsewhere or waiting >4s: disarms with no changes
   c. If other members exist — second click reveals an inline successor picker:
      - Shows non-admin members only
      - Selecting a member runs transfer+leave atomically
      - On success: router.push("/")
   d. If the admin is the last remaining member (no one else to transfer to):
      - Second click permanently deletes the list and all its movies
      - On success: router.push("/")

List Deletion flow (separate from self-removal):
   a. List Admin taps "Delete the list" in settings
   b. Shake-to-arm pattern: first click arms + shakes (4s auto-disarm), second click confirms
   c. Confirmed deletion removes the list and all its movies permanently
   d. On success: router.push("/")
   e. This action does NOT trigger the successor picker / ownership transfer

Master Admin Flow

1. Master Admin navigates to /admin
2. Login prompt: username + TOTP authenticator code (no password-only fallback)
3. Credentials (username and TOTP secret) are set via environment variables — no first-run UI
4. On successful auth → iron-session v8 issues encrypted HttpOnly cookie (8-hour expiry)
   → Master Admin dashboard
5. Available tools:
   - Search any list by name or ID → view details → delete (with confirmation)
   - Search any user by display name or ID → view details → delete (with confirmation)
     Deletion must remove both public.users row AND auth.users record
     (via supabase.auth.admin.deleteUser() using service role key)
6. Master Admin session is separate from regular user sessions
7. All /admin routes redirect to login if no valid admin session
8. TOTP secret rotation requires redeployment (documented operational constraint)

6. Usability Concerns

  • Mobile-first: All layouts designed for 375px+ screens first; desktop is a bonus. Grid expands to 3-4 columns on tablet/desktop.
  • PWA: App should be installable to home screen and behave like a native app in browser.
  • Movie info modal: Tapping a poster card opens ListMoreInfoModal (portal to document.body). The former inline ExpandedPanel has been replaced. Modal must use createPortal — any transformed ancestor (e.g., animate-emerge fill-mode: both) establishes a containing block that clamps fixed inset-0 to the ancestor's box.
  • Modal keyboard navigation: Modal must be operable via keyboard: ESC to close, focus trap on open, focus restoration on close.
  • Shared roll animation: Both the landing page and in-app rolls use the same horizontal poster-carousel mechanic (ListRollCarousel for in-app; landing carousel for landing). Animation is user-triggered only and completes within 5 seconds (WCAG 2.2.2). Target ~3.35 seconds total (500ms entrance + 100ms pause + 2750ms spin).
  • Reduced motion: All animations must respect prefers-reduced-motion. Landing page reel: instant reveal or simple fade-in. In-app roll (ListRollCarousel): animation skipped, result card appears instantly. Full animation remains the default. Search bar glow animation also respects prefers-reduced-motion.
  • Search bar UX: Wider (max-w-xl), centered text, bold "ADD A MOVIE" placeholder, white pulsating glow (@keyframes pulse-glow). Pulsating glow respects prefers-reduced-motion.
  • Search responsiveness: Debounce TMDB queries at ~300ms; show loading state during fetch.
  • Real-time updates: New movies and watched status changes appear without a page refresh via Supabase subscriptions.
  • Offline tolerance: If connection drops, the app should degrade gracefully — show cached list (via TanStack Query persistence), disable write actions with an explanatory message. Do not queue offline writes.
  • Accessibility: Sufficient color contrast on poster overlays; tap targets minimum 44x44px; screen reader labels on icon buttons; meaningful alt text on all poster images; aria-live="polite" regions for dynamic status messages (roll results, filter changes, watched state toggles, action confirmations).
  • TMDB API: All TMDB calls routed through server-side proxy (/api/tmdb/*) with include_adult=false and PG-13 certification filter. Cache recent search results via TanStack Query with explicit staleTime configuration.
  • Home page empty state: When a user has no lists, top create/join row is hidden; centered stacked large CTAs are the sole entry point. RollSection only renders when groups and movies both exist — no orphaned roll UI.

7. Technical Considerations

Tech Stack

Layer Choice Notes
Frontend Next.js (React, App Router) PWA support; output: 'standalone' for Docker
Styling Tailwind CSS Mobile-first, fast iteration
Backend / Database Supabase (self-hosted) Postgres + real-time subscriptions + GoTrue auth; full Docker stack
Auth Supabase Anonymous Sign-In via @supabase/ssr signInAnonymously() — no email, instant account, JWT for RLS; cookie-based session via createBrowserClient/createServerClient
Movie Data TMDB API Posters, genres, metadata, trailer URLs; all calls via server-side proxy with include_adult=false and PG-13 cert filter
State Management TanStack Query (React Query) Server state sync, caching with explicit staleTime, loading states
Admin Sessions iron-session v8 Encrypted HttpOnly cookie for Master Admin TOTP sessions (use v8 README directly — v7 patterns are incompatible)
2FA (Master Admin) TOTP via otplib (or equivalent) Authenticator-app compatible; TOTP secret never exposed client-side
PWA @serwist/next App Router compatible, Workbox 7; requires authoring app/sw.ts and tsconfig.worker.json
Image Optimization sharp (local assets only) TMDB posters use native sized URLs from TMDB CDN directly (not next/image)
Background Jobs Node.js cron container (node:22-alpine + node-cron) Runs alongside app in docker-compose; writes to Postgres via service role key
Reverse Proxy Caddy HTTPS termination (required for PWA, wss://, secure cookies); persistent volume for TLS certificates
Linting / Formatting ESLint + Prettier + TypeScript strict next/core-web-vitals + next/typescript presets; husky + lint-staged pre-commit
Testing Vitest (unit) + Playwright (E2E) Unit tests for pure logic and Client Components only (Vitest cannot render RSC); Playwright runs against Docker production stack
Env Validation t3-env (@t3-oss/env-nextjs) with zod Structural enforcement of server/client env var split at build time
Runtime Node.js 22 LTS node:22-slim Docker base image

TMDB Image Strategy

TMDB posters are served directly from TMDB's CDN using native sized URLs — not processed through next/image optimization (which would run sharp in-container and create CPU/memory pressure in Docker). Use these TMDB size variants:

  • w342 — grid thumbnails (mobile)
  • w185 — reel animation posters
  • w500 — movie info modal (ListMoreInfoModal)
  • Add loading="lazy" attribute on all poster <img> tags
  • Add meaningful alt text on all poster images (e.g., "Movie Title (Year) poster"); spinning reel posters use aria-hidden (decorative)
  • Reserve next/image for locally-served assets only (logo, icons)
  • Install sharp as an explicit production dependency for local asset optimization

TMDB Attribution

TMDB Terms of Service require visible attribution on every page: the TMDB logo, a link to themoviedb.org, and the disclaimer "This product uses the TMDB API but is not endorsed or certified by TMDB." Implemented as TMDBFooter with LegalFooter composed inside. Provided by (app), (auth), admin, and (public) segment layouts — no per-page inline duplication required. Non-compliance risks API key revocation.

TMDB API Proxy

All TMDB API calls must be routed through Next.js API Route Handlers (/api/tmdb/*). The TMDB_API_KEY environment variable must NEVER use the NEXT_PUBLIC_ prefix — it must remain server-side only. This is a TMDB Terms of Service requirement. The proxy also enables server-side response caching via Cache-Control headers.

Adult content policy (locked 2026-05-21): include_adult=false alone is insufficient. The full policy is PG-13-or-better via TMDB certification allowlist, applied belt-and-suspenders:

  • /discover, /popular, /reel-posters: use DISCOVER_CERT_PARAMS (certification_country=US&certification.lte=PG-13)
  • /search: post-filter via fetchAndFilterByCert (concurrency-capped detail fetches with append_to_response=release_dates)
  • /movie/[id]: 404 any title that fails isMovieAllowedByCert
  • Allowlist in src/lib/tmdb/certification.ts covers US/GB/DE/FR/AU/CA/NL/ES/IT/JP/KR/BR/MX/IE/SE
  • STRICT mode: titles with no recognized country cert are rejected

PINNED_REEL_POSTERS exception (locked 2026-05-21): Entries in PINNED_REEL_POSTERS (defined in src/app/api/tmdb/reel-posters/route.ts and mirrored in cron/index.ts) are the single sanctioned bypass of the cert filter, scoped exclusively to the landing carousel editorial surface. The cert filter is the default for all discoverable/searchable content; PINNED_REEL_POSTERS is a separately-curated editorial surface that may legitimately include titles outside the default cert window. Each addition to that list is a deliberate policy override and must be reviewed as such. The cron reel-refresh will skip the cert check for entries whose tmdb_id matches PINNED_REEL_POSTERS — and the landing_reel_posters backfill sweep that clears pre-filter stale rows will also exempt pinned entries. This bypass does not extend to any other surface: /api/tmdb/movie/[id] still cert-gates the info modal fetch, so a user can see a pinned poster in the carousel and click it, but the modal will 404. This UX/policy compromise is deliberate — the editorial pin is visible, but discoverability stops at the carousel. See Section 9b for full rationale.

Known gaps as of 2026-05-21 (tracked as open tasks):

  • cron/index.ts reel-poster refresh populates landing_reel_posters without cert filter — primary reel-poster path bypasses the new guard (task 5.12)
  • /api/tmdb/movie/[id]/videos (trailer endpoint) is not cert-gated — trailers reachable by ID for disallowed titles (task 5.13)

Data Model

users

  • id (UUID, primary key — maps to Supabase Auth UID from signInAnonymously())
  • display_name (text, CHECK: 1-30 characters, no HTML angle brackets or control characters, Unicode letters allowed)
  • avatar_color (text, hex)
  • recovery_code (text, hashed with Argon2id — memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes; 24 alphanumeric characters / 128-bit entropy; single-use)
  • last_active_at (timestamp — updated on write operations, throttled to once per 24 hours per user; used for 12-month retention policy)
  • created_at

groups

  • id (UUID, primary key)
  • name (text, CHECK: 1-50 characters, no HTML angle brackets or control characters, Unicode letters allowed)
  • invite_code (text, unique, WORD-WORD human-readable format; word list: 2,000+ words, 3-8 chars each, offensive/confusing terms filtered, uppercase display, case-insensitive comparison, collision check on generation)
  • created_by (FK → users.id)
  • created_at

group_members

  • group_id (FK → groups.id)
  • user_id (FK → users.id)
  • role (text: 'admin' | 'member')
  • joined_at

movies

  • id (UUID, primary key)
  • group_id (FK → groups.id)
  • tmdb_id (integer)
  • title (text)
  • year (integer)
  • poster_path (text, TMDB relative path — full URL constructed at render time using TMDB native sizes)
  • genres (text[], TMDB genre labels)
  • trailer_url (text, nullable — fetched from TMDB at add-time via server proxy and stored; validated against domain allowlist: youtube.com, themoviedb.org, imdb.com; background job refreshes only null entries on a bi-weekly cadence)
  • trailer_url_refreshed_at (timestamp — tracks when the trailer URL was last fetched, used by the refresh job)
  • metadata_refreshed_at (timestamp, nullable — tracks when title/poster/genres/year were last refreshed from TMDB; used by the monthly metadata refresh job post-MVP)
  • added_by (FK → users.id, ON DELETE SET NULL)
  • watched (boolean, default false)
  • watched_at (timestamp, nullable)
  • added_at
  • NOTE: overview (plot/description) is NOT stored. ListMoreInfoModal fetches it from TMDB per-open.

landing_reel_posters

  • id (integer, primary key)
  • tmdb_id (integer)
  • poster_path (text)
  • title (text)
  • refreshed_at (timestamp — set by the periodic reel refresh job)
  • (Table holds ~20 rows; entire set replaced on each refresh from TMDB popular/top-rated)

admin_sessions

  • Managed via iron-session v8 encrypted HttpOnly cookies; no database table required. Session expiry: 8 hours. Cookies set with Secure and SameSite=Strict flags.

Row Level Security (RLS)

RLS must be enabled on ALL tables with explicit policies — no permissive catch-all. Supabase's anon key is public by design; RLS is the authorization mechanism. Policies use auth.uid() from the JWT issued by Supabase Anonymous Sign-In. Define policies alongside the schema in Phase 1.2. Key rules:

  • users: Users can read/update only their own row
  • groups: Readable by members of the group only
  • group_members: Readable by members of the same group; joining is a server-side operation via service role key (not client INSERT); deletable by admins or self (leave); UPDATE must prevent role escalation (member cannot set own role to admin)
  • movies: Full CRUD for members of the owning group only; INSERT WITH CHECK must enforce added_by = auth.uid() (prevent attribution spoofing); UPDATE must prevent changing added_by — enforced by movies_added_by_immutable BEFORE UPDATE trigger (migration 00005) rather than a self-referencing WITH CHECK subquery (which causes Postgres error 42P17 infinite recursion)
  • landing_reel_posters: Readable by anyone (public); writable only by service role (cron job)

Supabase Realtime also respects RLS — subscriptions are authorized by the same policies.

Database Migrations

Use supabase migration new via the Supabase CLI. All migrations stored in version control. Migrations must be the sole mechanism for schema changes — no ad-hoc SQL in production.

Privacy

  • No email addresses collected in MVP
  • Display names only — no real identity data
  • Recovery codes hashed with Argon2id before storage
  • Invite codes are the primary access control mechanism for regular users; join endpoint rate-limited
  • Master Admin credentials (username) and TOTP secret are stored as environment variables server-side only; never exposed client-side
  • TMDB data is public — no privacy concern
  • Privacy policy page required with the following sections: controller identity, lawful basis per processing activity, data inventory with retention periods (anonymous UUID, display name, group membership, movie preferences, server/container logs with IPs), third-party recipients (TMDB API, Sentry), international transfer basis (Sentry — US servers), full user rights with exercise instructions (access, erasure, portability, objection), children's disclaimer (under-13/under-16), cookie/localStorage disclosure, change notification procedure
  • Legal footer (locked 2026-05-21): LegalFooter component composed inside TMDBFooter. Links to /privacy, CCPA anchor (/privacy#ccpa), GDPR anchor (/privacy#gdpr), cookie notice ("essential cookies for authentication"), © {year} MovieDice. Footer describes ACTUAL data practice only — deliberately omits email/phone storage. /terms does not exist; Terms link is omitted until terms content is authored. Do not add a Terms stub.
  • Data retention: Inactive accounts (no activity for 12 months) are automatically deleted. Auto-deletion must handle orphaned groups: auto-transfer admin to longest-tenured member; cascade-delete group if last member. added_by FK uses ON DELETE SET NULL to prevent FK violations. Deletion wrapped in a transaction per user. Account deletion must also call supabase.auth.admin.deleteUser(userId) to remove the auth.users record. Users cannot be notified before deletion due to anonymous auth (no email). This is documented in the privacy policy.
  • TMDB attribution displayed on all pages per Terms of Service
  • Sentry data sanitization: Configure beforeSend callback to strip UUID path segments from error events; do not call Sentry.setUser() with user identifiers. Disclose Sentry as a third-party processor in the privacy policy.
  • Container logs: Supabase containers (Kong, GoTrue, PostgREST, Realtime) produce logs containing IP addresses and JWTs. Docker log rotation configured on all containers (max-size: 10m, max-file: 5) to bound retention to ~30 days.

Security Headers

Configure HTTP security headers at the Caddy reverse proxy level (not in next.config.js):

  • Content-Security-Policy — restrict img-src to image.tmdb.org, connect-src to 'self' for API calls and wss://[deployment-domain] for Supabase Realtime WebSocket (not *.supabase.co — self-hosted routes through own domain)
  • X-Frame-Options: DENY
  • X-Content-Type-Options: nosniff
  • Referrer-Policy: strict-origin-when-cross-origin
  • Strict-Transport-Security (HSTS) — configured in Caddyfile; start with short max-age (86400) during testing, increase to 2-year production value before launch; do not submit to HSTS preload list until confident
  • Permissions-Policy
  • Use Content-Security-Policy-Report-Only during development to identify violations without blocking

Deployment

  • Self-hosted Docker deployment orchestrated via docker-compose
  • Next.js app container: multi-stage Dockerfile with node:22-slim, output: 'standalone', non-root user, tini for PID 1 signal handling, .dockerignore; builder stage must install python3 make g++ for argon2 native build (alternative: @node-rs/argon2 with pre-compiled NAPI binaries eliminates node-gyp requirement)
  • Supabase self-hosted: full Docker stack (Postgres, GoTrue, Realtime, PostgREST, Kong, Studio) using Supabase's official docker-compose configuration adapted for this project
  • Supabase secret replacement (MANDATORY before first deployment): ALL default secrets must be replaced before the first docker compose up. Defaults are published on GitHub — a default JWT_SECRET allows forging JWTs that bypass all RLS. Replace these as a lockstep set: JWT_SECRET → regenerate both ANON_KEY and SERVICE_ROLE_KEY (they derive from JWT_SECRET); also replace POSTGRES_PASSWORD, DASHBOARD_USERNAME, DASHBOARD_PASSWORD. Consider adding a startup check that refuses to start if default values are detected.
  • GoTrue configuration: Set GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true (disabled by default — without it, signInAnonymously() returns 400); disable all other auth methods: GOTRUE_EXTERNAL_EMAIL_ENABLED=false, GOTRUE_EXTERNAL_PHONE_ENABLED=false, all OAuth providers disabled
  • Network security: Kong ports (8000, 8443) and Postgres port (5432) must be internal to the Docker network only — no host port mapping. Supabase Studio must not be publicly accessible — restrict to 127.0.0.1:3000 or remove from production docker-compose; access via SSH tunnel only. Only Caddy is exposed to the internet.
  • Reverse proxy: Caddy for HTTPS termination (required for PWA service workers, wss:// Supabase Realtime, and secure cookies); Caddy /data and /config directories must be mounted as persistent named Docker volumes (certificate loss + Let's Encrypt rate limits = up to 1 week downtime); use Let's Encrypt staging endpoint for initial testing
  • Health check: /api/health endpoint checking Supabase connectivity; used by Docker HEALTHCHECK
  • Background jobs: Node.js cron container (node:22-alpine + node-cron) running alongside the app in docker-compose; connects to Postgres via SUPABASE_SERVICE_ROLE_KEY; handles landing reel refresh and trailer URL refresh
  • Database backups: pg_dump backup container in docker-compose running daily with 7-day retention; document restore procedure; test restore before launch
  • Docker log rotation: All containers must configure Docker logging with max-size: 10m, max-file: 5 to prevent disk exhaustion and bound GDPR log retention
  • Disk encryption recommendation: Enable full-disk encryption on the Docker host (LUKS or cloud provider equivalent) to protect Postgres volume data at rest
  • Environment variables required:
    • TMDB_API_KEY (server-side only — never NEXT_PUBLIC_)
    • NEXT_PUBLIC_SUPABASE_URL — the public Supabase URL (browser client uses this)
    • NEXT_PUBLIC_SUPABASE_ANON_KEY — the public anon key (browser client uses this; public by design in Supabase's security model)
    • SUPABASE_INTERNAL_URL — Docker internal Kong URL (e.g., http://supabase_kong:8000; server-side Next.js code uses this to avoid routing through Caddy)
    • SUPABASE_SERVICE_ROLE_KEY (for server-side admin operations; never exposed client-side)
    • MASTER_ADMIN_USERNAME — the master admin login username
    • MASTER_ADMIN_TOTP_SECRET — the TOTP secret (base32); configure this in your authenticator app (e.g., Google Authenticator, Authy) before first use
    • IRON_SESSION_SECRET — 32+ character secret for iron-session cookie encryption
    • GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true — required for anonymous sign-in
  • All environment variables validated at startup via t3-env (@t3-oss/env-nextjs) with zod; server vars in server block, public vars in client block; missing or malformed variables produce a clear build/startup failure
  • No first-run setup UI exists; the master admin account is fully configured via environment variables before deployment
  • CI: Linting, type-checking, and build validation enforced via husky pre-push hook (no external CI platform required)

8. Implementation Plan

MVP Deadline: April 26, 2026 Full Feature Complete: July 5, 2026


Phase 1: Foundation (April 6-10, 2026) — MVP

  • 1.1 — Initialize Next.js project with Tailwind CSS, App Router, and output: 'standalone' in next.config.ts; configure TypeScript strict mode, ESLint (next/core-web-vitals + next/typescript), Prettier; set up husky + lint-staged (pre-commit: lint/format, pre-push: lint + typecheck + build); install sharp as production dependency; add Vitest for unit testing (scope: pure logic and Client Components only — Vitest cannot render RSC)

VERIFIED (2026-05-07): Project initialized, TypeScript strict, ESLint, Prettier, husky pre-commit (lint/format), and Vitest all in place. Lint/typecheck/build run in CI and locally. SUB-TODO: pre-push hook (lint + typecheck + build) is not yet wired — currently only CI and manual runs cover this. Wire husky pre-push before first external collaborator onboards.

  • 1.2 — Set up self-hosted Supabase Docker stack; replace ALL default secrets before first docker compose up (JWT_SECRET → regenerate ANON_KEY + SERVICE_ROLE_KEY together; replace POSTGRES_PASSWORD, DASHBOARD_USERNAME, DASHBOARD_PASSWORD); configure GoTrue: set GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true, disable email/phone/OAuth auth methods; create schema (users, groups, group_members, movies, landing_reel_posters tables) with CHECK constraints on display_name and group name; define and enable RLS policies on all tables with WITH CHECK clauses (see RLS section); initialize Supabase CLI migrations workflow — all schema changes via supabase migration new

RLS HOTFIX SHIPPED (2026-05-06, migration supabase/migrations/00005_movies_update_recursion_fix.sql): Symptom — PATCH /api/movies/[id]/watched returned 500 with Postgres error 42P17 ("infinite recursion detected in policy for relation 'movies'"). Root cause — the original movies_update RLS policy's WITH CHECK clause referenced public.movies in a subquery to enforce added_by immutability; selecting from movies while the movies_update policy evaluates triggers RLS recursion. Fix — dropped the self-referencing WITH CHECK; replaced with a BEFORE UPDATE trigger movies_added_by_immutable that raises exception 23514 if NEW.added_by IS DISTINCT FROM OLD.added_by. Group-membership check remains in the policy. NOTE: other RLS policies should be audited for similar self-referencing subqueries — see task 1.9.

  • 1.3 — Configure Supabase client in Next.js using @supabase/ssr (createBrowserClient for browser, createServerClient for server-side with SUPABASE_INTERNAL_URL); implement env var validation at startup via t3-env (@t3-oss/env-nextjs) with zod — server vars in server block, client vars in client block; create TMDB API proxy route (/api/tmdb/*) to keep API key server-side; set include_adult=false on all TMDB calls
  • 1.4 — Implement anonymous auth via supabase.auth.signInAnonymously(); display name input and optional avatar color picker; session managed by Supabase GoTrue via @supabase/ssr cookie-based handling (JWT issued automatically); verify GoTrue returns 200 (not 400) — confirm GOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true is set
  • 1.5 — Implement recovery code generation (24 alphanumeric characters, 128-bit entropy), Argon2id hashing (explicit OWASP parameters: memory=19456 KiB, iterations=2, parallelism=1, output=32 bytes — do not use library defaults), and show-once display screen. NOTE (2026-05-02, commits f71cb76 + a54a5ca): Fixed a post-signup race on the /recovery page where React 19 StrictMode double-mount caused the page to hang on "Generating your recovery code..." indefinitely (reload was required to see the code). Root cause: the original useMutation + useRef guard fired on the first mount instance; the second instance never observed the response. Fixed by replacing useMutation with useQuery (key: recovery-code-generate, staleTime/gcTime: Infinity, refetchOnMount: false) — TanStack Query dedupes the request across mount/remount/StrictMode and persists the result in cache. Server route (POST /api/auth/recovery/generate) unchanged.
  • 1.6 — Implement recovery code claim flow (enter code → verify against Argon2id hash → restore identity on new device); rate-limit claim endpoint (5 failed attempts per IP per 15-minute window); invalidate code after successful claim. VERIFIED (2026-05-06): Manual end-to-end browser test passed. Both escalation triggers cleared: (A) auth.identities row present and correct — no issue; (B) GoTrue #2013 residual — not triggered with pinned v2.170.0. This was the highest-risk open item in the recovery system: GoTrue accepting our minted JWTs via HS256 signature alone (without a matching auth.sessions row) was the critical architectural unknown. Browser verification clears it — the synthetic-identity-at-generate architecture is sound. Architectural risk for the recovery system is resolved.

ARCHITECTURE — Synthetic-identity-at-generate (replaces broken claim-only-verifies-no-session flow): At code-generation time, /api/auth/recovery/generate assigns <uid>@moviedice.invalid + HKDF-SHA256 derived password (label "moviedice-recovery-v1", salt=uid) to the GoTrue user via admin API. Returns 409 if a code already exists (idempotent — no auto-rotate). Atomic rollback on failure (admin.deleteUser + 500). At claim time, /api/auth/recovery/claim runs Argon2 scan → signInWithPassword with derived synthetic creds → cookies set via createServerClient bound to response → { ok: true }. /recover page calls window.location.assign("/") post-claim (hard nav for cookie propagation). New files: src/lib/auth/synthetic.ts (syntheticEmail(), derivePassword()), supabase/migrations/00003_synthetic_email_constraint.sql (CHECK on auth.users.email rejecting @moviedice.invalid on non-promoted rows), src/lib/supabase/admin.ts updated with module-load assertion on GOTRUE_EXTERNAL_EMAIL_ENABLED. Tests: 15 passing (synthetic.test.ts ×6, recovery-generate.test.ts ×5, recovery-claim.test.ts ×4) + 1 todo regression scaffold for GoTrue #2013 canary.

  • 1.7 — Build Docker infrastructure: multi-stage Dockerfile (node:22-slim, non-root user, tini; builder stage installs python3 make g++ for argon2 native build), docker-compose.yml orchestrating Next.js app + self-hosted Supabase stack + Caddy reverse proxy + Node.js cron container + pg_dump backup container (daily, 7-day retention), .dockerignore, /api/health endpoint; network security: Kong/Postgres ports internal only, Studio restricted to 127.0.0.1; Caddy persistent volumes for /data and /config (TLS certificates); Docker log rotation on all containers (max-size: 10m, max-file: 5); use Let's Encrypt staging for initial testing; deploy and confirm Supabase connection works in production

NOTE (2026-05-07): Docker infrastructure code (Dockerfile, docker-compose.yml, Caddy config, cron container, health endpoint) is complete and runs locally. OPEN: app has never been deployed to a real server — production deploy and smoke test remain pending (see also 5.8).

OPEN BUG (2026-05-20): docker compose up --build fails — npm ci inside the Dockerfile builder stage exits 1. Workaround during this session was to use the host's pre-built .next/standalone. Root cause not yet investigated. Blocks clean image builds; must be resolved before any production deployment.

  • 1.8 — Add Sentry error monitoring (free tier); configure beforeSend to strip UUID path segments from error events; do not call Sentry.setUser() with user identifiers
  • 1.9 — RLS policy audit: review all remaining RLS policies (users, groups, group_members, landing_reel_posters) for WITH CHECK or USING clauses that contain self-referencing subqueries against the same table being evaluated — these cause Postgres error 42P17 (infinite recursion). The movies_update recursion was fixed in migration 00005 via trigger; confirm no other policies contain the same pattern. Document findings.

VERIFIED (2026-05-07): Clean migrations confirmed; no other policies contain self-referencing subqueries. CI guard covers future migrations. Audit is sufficient documentation for pre-launch.

Phase 2: Groups and Permissions (April 10-14, 2026) — MVP

  • 2.1 — Build "Create a Group" flow: name input (validated: 1-50 chars), invite code generation (WORD-WORD format; word list: 2,000+ words, 3-8 chars, offensive terms filtered, collision check), store in DB, assign creator as List Admin; document API routes in markdown
  • 2.2 — Build "Join with a Code" flow: code entry, validation via server-side route handler using service role key (not client-side INSERT), group_members record with role: 'member'; rate-limit join endpoint (5-10 failed attempts per IP per 15-minute window); document API routes in markdown
  • 2.3 — Build logged-in home page: mirrors landing page layout; replaces Login with "Create List" button; shows user's list cards (list name left, movie count + film emoji right, "Created by: [username]" below); tapping a card navigates to that list; Roll the Dice and Genre Roll on the home page roll across all lists combined and display the result as a standalone teaser card on the home page (no navigation into a specific list); use polling (not real-time subscriptions) for home page movie counts.

VERIFIED (2026-05-07): Cross-list roll confirmed working — rolls across all user lists combined, result displays via ListRollCarousel in place with no navigation into any list.

UI RESTRUCTURE SHIPPED (2026-05-21): useUserGroups/useAllUserMovies checks gate all home page sections. Empty-state path is centered stacked large CTAs (Create List + Join a List, size="large" prop on JoinListButton). Top create/join row hidden when no lists. RollSection only renders when both groups and movies exist. EmptyState component no longer renders duplicate Create/Join buttons. Roll buttons restyled to match RollBar (red random / purple genre, min-h-[44px]).

HOME PAGE POLISH SHIPPED (2026-05-23): Create/Join equal-width pair finalized — sm:flex-1 baked into all three JoinListButton variants (collapsed, open-form, large); wrapper <div className="sm:flex-1"> indirection removed from home/page.tsx; JoinListButton is now a direct flex sibling of the Create List link. Collapsed shape mirrors roll-bar buttons (inline-flex min-h-[44px] w-full sm:flex-1 ... px-4 py-2 text-sm font-semibold); container uses sm:items-center. Roll buttons now correctly hide when all lists have zero movies while Create/Join row stays visible — Create/Join lifted out of RollSection into home/page.tsx, rendered whenever hasGroups independent of movie count; RollSection no longer needs useUserGroups.

  • 2.4 — Implement List Admin settings. SHIPPED (2026-05-06): Settings page src/app/(app)/list/[id]/settings/page.tsx now exists (gear icon was previously broken/404); mounts src/components/groups/settings-panel.tsx. Join code displayed in list header under list name (src/app/(app)/list/[id]/page.tsx selects invite_code, renders "Join code: WORD-WORD"). Delete/leave/transfer actions use shake-to-arm pattern (animate-shake, 4s auto-disarm) replacing all window.confirm() popups. Admin with other members: second click reveals inline non-admin successor picker; selecting a member runs transfer+leave atomically → router.push("/"). Admin last member: second click permanently deletes → router.push("/"). Settings also supports: rename list, regenerate invite code (copy-to-clipboard), view + remove members.

SETTINGS CLEANUP SHIPPED (2026-05-21): Settings page header redesigned as 3-col grid (SVG back arrow left, centered "List Settings" title, empty right). Invite-code section heading and display chip removed from settings (code now prominently in list header). Regenerate and copy preserved as inline text actions with aria-labels. Rename section heading changed "Rename Group" → "Rename List"; form is flex-col gap-2 sm:flex-row; button labeled "Save name". Text bumped one step throughout.

LIST HEADER JOIN CODE REDESIGN SHIPPED (2026-05-21): Invite code now displayed as large text-3xl sm:text-4xl font-mono tracking-widest with "Invite Friends to Add Movies!" eyebrow above. Old small mono chip removed.

  • 2.5 — Implement List Admin direct deletion. SHIPPED (2026-05-06): "Delete the list" in SettingsPanel uses shake-to-arm (first click arms + shakes, 4s auto-disarm, second click confirms permanent deletion) → router.push("/"). Does NOT trigger the successor/ownership picker.
  • 2.6 — Implement regular user settings. SHIPPED (2026-05-06): "Leave this list" for non-admin members uses same shake-to-arm pattern for consistency → router.push("/") on confirm.

Phase 3: Movie List Core (April 14-20, 2026) — MVP

  • 3.1 — Integrate TMDB API via server-side proxy (/api/tmdb/*): search endpoint with include_adult=false and server-side adult field filtering, poster URL construction using TMDB native sizes (w342 grid, w185 reel, w500 modal), trailer URL fetch at add-time; implement TanStack Query caching with explicit staleTime configuration; document API routes in markdown.

ADULT CONTENT HARDENING SHIPPED (2026-05-21): include_adult=false replaced/supplemented with full PG-13 certification allowlist. /discover, /popular, /reel-posters use DISCOVER_CERT_PARAMS. /search post-filters via fetchAndFilterByCert. /movie/[id] 404s non-allowlisted titles via isMovieAllowedByCert. Implementation: src/lib/tmdb/certification.ts.

  • 3.2 — Build search bar with ~300ms debounce, loading state, and two-section results ("In Your List" above separator, TMDB results below).

SEARCH BAR RESTYLE SHIPPED (2026-05-21): max-w-xl, centered text, bold "ADD A MOVIE" placeholder, white pulsating glow (@keyframes pulse-glow in globals.css, prefers-reduced-motion honored).

SEARCH BAR POLISH SHIPPED (2026-05-23): Added pl-20 to balance pr-20 so text-center lands the placeholder dead-center. pulse-glow keyframes in globals.css rewritten as a two-layer gold halo (rgba(250,204,21,…) matching roll-teaser tone) — visible in both light and dark modes. prefers-reduced-motion opt-out preserved.

  • 3.3 — Implement add-movie flow: tap result → insert into movies table with TMDB metadata, stored trailer_url (fetched via server proxy at add-time; validated against domain allowlist: youtube.com, themoviedb.org, imdb.com), trailer_url_refreshed_at, and added_by attribution; document API routes in markdown

BUG FIX SHIPPED (2026-05-06, master — uncommitted): Clicking "+ Add" on a search result flashed the button but silently failed. Root cause: src/app/api/movies/route.ts was calling TMDB with ?api_key=${TMDB_API_KEY} (v3 query-string auth), but TMDB_API_KEY is a v4 read-access JWT that TMDB only accepts via Authorization: Bearer. Every other TMDB call in the repo (src/lib/tmdb-fetch.ts, src/lib/tmdb/client.ts) already used Bearer — only this route was wrong. TMDB returned 401 → route returned 404 "Movie not found on TMDB" → useAddMovie threw silently (no onError handler) → invisible failure to the user. Fix: switched both TMDB fetches in that route to Authorization: Bearer ${TMDB_API_KEY} headers; added console.error with TMDB response body+status on details-fetch failure. UX fix in the same pass (src/components/movies/movie-list-client.tsx): clicking a search result now immediately hides the results panel (resultsHidden state, reset on next keystroke); search input text is preserved.

Known follow-ups not addressed in this fix: (1) useAddMovie hook still has no onError handler — add-movie failures remain silent in the UI; (2) Turbopack did not recompile the API route after edits during this session — required manual next dev restart + .next cache wipe. Note if it recurs.

DEAD CODE (2026-05-06 UI batch — uncommitted): The following files are no longer imported anywhere and should be deleted:

  • src/components/movies/expanded-panel.tsx — replaced by ListMoreInfoModal
  • src/components/movies/movie-search-panel.tsx — never wired up
  • src/components/home/home-roll-teaser-card.tsx — home now uses ListRollCarousel
  • src/components/dice/roll-animation.tsx — home and list both now use ListRollCarousel
  • src/components/dice/roll-result-card.tsx — list used it before; list now uses ListRollCarousel

  • [x] 3.4 — Build 2-column poster grid. SHIPPED (2026-05-06): PosterCard is now a full-card button (no ExpandedPanel). Tapping opens ListMoreInfoModal. "i" glyph bottom-right is decorative pointer-events-none. Watched overlay changed from binoculars emoji to green-circle checkmark top-left. Avatar dot remains top-right. Grid layout unchanged (w342 posters, loading="lazy", meaningful alt text, added-by avatar overlaid top-right).

  • [x] 3.5 — Implement infinite scroll: load 12 movies initially, fetch and append next batch automatically on scroll to bottom

  • [x] 3.6 — Build movie info modal. SHIPPED (2026-05-06): src/components/dice/list-more-info-modal.tsx. Replaces the planned inline ExpandedPanel entirely. See "Movie Info Modal" flow and feature table entry for full spec. Portal to document.body; TMDB overview fetched per-open; Watched + Delete both use shake-to-arm three-state pattern.

  • [x] 3.7 — Genre filter (scope clarified 2026-05-06). DELIBERATE SCOPE REDUCTION — not a regression. Genre tags are display-only everywhere except Genre Roll. Genre Roll (RollBar / RollSection) is the sole filter entry point — tappable tag-filters have been explicitly removed from the product design when ExpandedPanel was replaced by ListMoreInfoModal. Cleanup in flight: dormant selectedGenre prop being removed from PosterGrid.

  • [ ] 3.8 — Implement Watched state: toggle marks/unmarks movie as watched for the group; watched movies move to collapsed "Watched" section; green-circle checkmark overlay and button color update simultaneously across all members; announce state change via aria-live="polite" region

NOTE (2026-05-06): The PATCH /api/movies/[id]/watched endpoint was 500ing due to Postgres error 42P17 (RLS recursion). Fixed by migration 00005 (see task 1.2 note). The API layer is now unblocked. Remaining work: collapsed "Watched" section UI in the grid, real-time propagation of watched state to all group members (via task 3.9), and aria-live announcement.

KNOWN BUG (2026-05-07): Real-time UPDATE events for watched state do not propagate to other connected clients. The watching user's UI updates correctly (optimistic update), but a second browser window on the same list requires a manual refresh to see the change. INSERT (add movie) and DELETE (remove movie) real-time events propagate correctly — only UPDATE (watched toggle) is affected. Root cause not yet investigated; likely a Supabase Realtime subscription filter or event type mismatch.

  • 3.9 — Enable Supabase real-time subscriptions on movies table for live add, remove, and watched-status updates; subscribe only to the currently-viewed list (subscribe on mount, unsubscribe on unmount via useEffect cleanup); implement exponential backoff on reconnection

Phase 4: Randomizer (April 20-23, 2026) — MVP

  • 4.1 — Build "Roll the Dice!" and "Genre Roll!" button layout. SHIPPED (2026-05-06): List view — RollBar moved ABOVE search bar; Roll + Genre stack full-width on mobile (flex-col items-stretch w-full), side-by-side on desktop (sm:flex-row sm:flex-1). Home — Roll + Genre + Create List in RollSection; same mobile/desktop pattern with sm:justify-center. Create List button bg-blue-700 hover:bg-blue-600. Landing — Roll + Genre stack full-width on mobile (max-w-xs flex-col), desktop unchanged.
  • 4.2 — Implement randomizer logic. SHIPPED: useRoll hook selects a random unwatched movie from the eligible pool (single list on list page; all user lists combined on home page). Home page result displayed via ListRollCarousel in place — no navigation. aria-live="polite" region announces result (minor timing mismatch noted — RollAnnouncer fires at 2500ms, carousel settles at ~3350ms).
  • 4.3 — Build in-app roll animation. SHIPPED (2026-05-06): src/components/dice/list-roll-carousel.tsx. Horizontal poster strip (tiles to ≥10 by duplication when list is short); 500ms dice-emerge entrance → 100ms pause → 2750ms easeOutCubic spin → snap to gap-at-center → ±110px spread → animate-emerge result card with gold glow. Replaces scatter/flip/spin design. Respects prefers-reduced-motion.

ROLL RESULT CLICK TARGET SHIPPED (2026-05-21): ListTeaserCard in ListRollCarousel is now a full <button> — the entire card is the click target and opens ListMoreInfoModal. The small "i" glyph is decorative (aria-hidden).

  • 4.4 — Implement re-roll. SHIPPED: tapping Roll again re-rolls from the same eligible pool.
  • 4.5 — Build Genre Roll text input UI. SHIPPED: Genre Roll modal/input wired on both list and home pages.
  • 4.6 — Implement emotion-to-genre mapping. SHIPPED: static TypeScript constant; genre labels translated to TMDB numeric genre IDs.
  • 4.7 — Implement genre + emotion filter logic. SHIPPED: normalize input (lowercase, tokenize on spaces/commas), map emotions to TMDB genre IDs, filter unwatched pool; if no matches, show "No matches — showing full list" and proceed unfiltered. NOTE: home-view Genre Roll uses filterByGenresAndEmotionsStructured (2026-04-14) — fixes genre-only rolls silently discarding the filter when movies.genres stores TMDB names, not numeric IDs.
  • 4.8 — Apply roll animation to genre-filtered results. SHIPPED: ListRollCarousel receives the filtered pool directly.

Phase 5: Landing Page and MVP Polish (April 23-26, 2026) — MVP CUTOFF

  • 5.1 — Build landing page structure: centered logo, splash text, Roll + Genre Roll buttons, Login button, scrolling About section, 3-step how-it-works demo (Step 1 left, Step 2 right, Step 3 left); add site-wide footer with TMDB attribution (logo + link + disclaimer), privacy policy link, and legal footer (© MovieDice, CCPA/GDPR anchors, cookie notice); build privacy policy page with required sections (controller identity, lawful basis, data inventory, third-party recipients including Sentry, international transfers, user rights, children's disclaimer, cookie/localStorage disclosure, change notification procedure). NOTE (2026-05-02): Hero, Carousel, About, HowItWorks, Login link, and TMDBFooter all present in src/app/(public)/page.tsx + (public)/layout.tsx. Duplicate stub src/app/page.tsx removed so (public)/page.tsx is no longer shadowed.

TMDB ATTRIBUTION SITEWIDE SHIPPED (2026-05-21): TMDBFooter (with LegalFooter composed inside) now provided by (app), (auth), admin, and (public) segment layouts. Inline duplicate removed from /list/[id].

LEGAL FOOTER SHIPPED (2026-05-21): src/components/shared/legal-footer.tsx composed inside TMDBFooter. Links: /privacy, /privacy#ccpa, /privacy#gdpr, cookie notice. © {year} MovieDice. Deliberately omits email/phone storage. No Terms link — /terms does not exist (do not create until content is ready).

  • [~] 5.2 — Build and seed landing_reel_posters table; implement periodic refresh job in the Node.js cron container on bi-weekly cadence that automatically fetches ~20 posters from TMDB popular or top-rated endpoints (with include_adult=false) and replaces the full set in the DB on each run. NOTE (2026-05-02): Table exists and is populated by seed.sql. PINNED_REEL_POSTERS constant in the API route always prepends hard-coded entries (e.g., tmdb_id 615 "The Passion of the Christ"). OUTSTANDING: cron/index.ts reel-refresh handler is still a TODO — the /api/tmdb/reel-posters route currently fetches TMDB live on each request and ignores the DB table. Cron wiring is the remaining gap for this task.

NOTE (2026-05-21): When the cron reel-refresh handler is implemented, it must apply the PG-13 cert filter — see task 5.12. Do not implement the cron refresh without addressing the cert gap simultaneously. PINNED_REEL_POSTERS entries are exempt from both the cert filter and the backfill sweep that clears pre-filter stale rows — their inclusion is a deliberate editorial override. See Section 9b for the locked policy decision.

  • 5.3 — Build slot-machine reel animation for the landing page Roll the Dice button: 3 side-by-side reels that spin through the automatically fetched poster set (using w185 size, aria-hidden on spinning posters), decelerate, and land on a single TMDB movie result with alt text (the final result is fetched live from TMDB via server proxy — not constrained to the reel poster set); animation is user-triggered only and completes within 5 seconds (WCAG 2.2.2); respect prefers-reduced-motion (instant reveal or simple fade-in when enabled). NOTE (2026-05-02): Continuous carousel and slot-machine spin on roll are implemented. KNOWN BUG (U9): modulo wrap missing in spinning branch causes scroll offset to drift on repeated rolls; fix tracked in research/PHASE5_UI_FIXES_PLAN.md. NOTE (2026-05-02 dea71d9/0061375): Roll result card now emerges inside the carousel center via animate-emerge keyframe (scale 0→1.08→1, ~500ms, bouncy, skipped under prefers-reduced-motion); snap math positions midpoint of a gap at viewport center so card sits in a symmetric slot; posters spread ±110px on settle. Card stays settled until user re-rolls or refreshes (RESUME_DELAY auto-resume timer removed). TeaserCard reduced to ~1.3x reel poster size. "i" info button on TeaserCard opens (src/components/landing/more-info-modal.tsx) with title+year, genres, plot, Add to list (→ /login, no save-intent), Watch Trailer (lazy-fetches /api/tmdb/movie/[id]/videos); mirrors GenreRollModal a11y pattern; uses createPortal to escape the transformed ancestor. Gold glow added to TeaserCard in same UI batch as in-app roll (2026-05-06). NOTE: landing MoreInfoModal is distinct from the in-app ListMoreInfoModal (src/components/dice/list-more-info-modal.tsx).
  • 5.3a — Wire landing page Roll the Dice to TMDB via /api/tmdb/popular?page=N (random page 1–50, pool ~1000 movies). Result displayed as a static teaser card (poster with alt text, title, genres) — no link, no tap action. NOTE: this is a distinct pool from the carousel reel — see Feature Flow section 5 for the two-pool distinction.
  • 5.4 — Wire landing page Genre Roll to TMDB via server proxy (no login required); display result as a static teaser card showing poster (with alt text), title, and genres — no link, no tap action.
  • 5.5 — Loading and empty states for all major views (empty list, no search results, no genre matches, empty home page for new users)
  • VERIFIED (2026-05-07): User-confirmed adequate empty and loading states across tested views.

    ROLLBAR EMPTY-STATE SHIPPED (2026-05-21): RollBar is hidden when allMovies.length === 0 in movie-list-client.tsx — prevents roll UI appearing on an empty list.

    • 5.6 — Error handling: invalid invite code, TMDB API failure, network errors

    PARTIAL (2026-05-07): Most tested error paths are handled correctly. KNOWN GAP: with network set to offline, the add-movie search silently shows "No results" instead of a network-error indicator — a misleading false-empty state. Remaining sub-todo: detect offline/fetch-failure in the search path and show a distinct error message rather than an empty results list.

    • 5.7 — Configure HTTP security headers in Caddyfile: CSP (with self-hosted URLs, not *.supabase.co), X-Frame-Options, X-Content-Type-Options, Referrer-Policy, HSTS (start with max-age=86400, increase before launch), Permissions-Policy; use Report-Only mode during testing
    • 5.8 — Final MVP smoke test and Docker production deployment

    NOTE (2026-05-07): App has only run locally — no production server deployment has occurred. This task and 1.7's production deploy step remain fully open.

    • 5.9 — Implement user-facing sign-out. SHIPPED (2026-05-02, commits 44ff76c582a7fdadd8745): POST /api/auth/signout calls supabase.auth.signOut() server-side and returns Set-Cookie clears; GET/POST /logout is a redirect-to-/ alias for manual/debug links. <SignOutButton /> (src/components/auth/sign-out-button.tsx) placed in the (app) layout header — no avatar/menu. On click: calls API, clears TanStack Query cache, hard-navigates to /. No confirmation dialog (decided: anonymous-account warning is deliberately omitted; click = signed out). Distinct from /api/admin/logout (iron-session). No Clear-Site-Data header (no service worker yet). persistQueryClient cleanup deferred to Phase 8.

    DEV-ENV QUIRK — cookie-name pinning: SUPABASE_INTERNAL_URL and NEXT_PUBLIC_SUPABASE_URL may differ in dev (different hostnames). The Supabase SDK derives the session cookie name from the URL, so each side silently derived a different name, making signOut/getUser no-ops. Fixed by pinning cookieOptions.name to a value derived from NEXT_PUBLIC_SUPABASE_URL in src/lib/supabase/cookie-name.ts. Any new createServerClient call must include this cookieOptions.name — see also CLAUDE.md.

    TRUST-BOUNDARY FIX (2026-05-20): supabase.auth.signOut() was throwing unhandled AuthSessionMissingError because GoTrue does not recognize our minted JWTs (no matching auth.sessions row). Removed the GoTrue signOut call entirely from both POST /api/auth/signout and GET /logout. Replaced with own session revocation: getCurrentUser(req) to identify the session, then revokeSession to set user_sessions.revoked_at, then cookie clear by name. Behavior from the user's perspective is unchanged.

    • [x] 5.10 — Dev-environment and auth infrastructure hardening (2026-05-20). Covers four distinct fixes shipped in this session:

      1. Dev-server orchestration: npm run dev now auto-boots the full Supabase stack (db, auth, rest, realtime + schema-init + tenant-seed, kong, meta) via a predev script in package.json. npm run dev:down tears the stack down. Eliminates the manual docker compose up prerequisite for local development.

      2. Kong 2.8.1 env-var interpolation fix: ${ANON_KEY} and ${SERVICE_ROLE_KEY} in supabase/kong/kong.yml were stored as literal strings in Kong's key-auth credential store — Kong 2.8.1 does not perform env-var substitution on its declarative config. Every PostgREST call through Kong was silently returning 401 unless the caller happened to use the literal string as its key. Fixed via a custom Kong entrypoint in docker-compose.yml that runs sed to substitute env values into /tmp/kong.yml at container startup. Service-role and anon calls now authenticate correctly end-to-end through Kong. This was a latent dev-blocking bug present since the initial Kong setup.

      3. GoTrue DISABLE_SIGNUP fix: /api/auth/bootstrap was returning 500 (signup_disabled). GoTrue v2.170.0 enforces DISABLE_SIGNUP=true over ANONYMOUS_USERS_ENABLED=true when both are set. Flipped GOTRUE_DISABLE_SIGNUP to "false" in docker-compose.yml. Compensating access controls are enforced at the Kong layer (see item 4).

      4. Kong denylist for unused GoTrue surfaces: Added request-termination routes in supabase/kong/kong.yml returning 404 for /auth/v1/magiclink, /recover, /otp, /resend, /sso, /sso/saml. Public /auth/v1/signup remains reachable (required by signInAnonymously()) but registrations cannot be confirmed (no SMTP + MAILER_AUTOCONFIRM=false). Regression guard: src/__tests__/guards/kong-auth-denylist.test.ts.

      5. Bootstrap daily-cap robustness: Empty/malformed PostgREST error responses no longer cause /api/auth/bootstrap to 500. The handler now logs and treats empty errors as zero — the next call will catch the cap if it is genuinely exceeded.

    • [ ] 5.11 — Bootstrap bypass-Kong refactor (follow-up, deferred). Currently /api/auth/bootstrap routes through Kong, which means /auth/v1/signup must remain reachable at Kong (required for signInAnonymously()). Refactoring bootstrap to call GoTrue directly via SUPABASE_INTERNAL_URL (bypassing Kong) would allow /auth/v1/signup to be added to the Kong denylist, closing the surface entirely. This touches the trust boundary and warrants its own PR. Low urgency while MAILER_AUTOCONFIRM=false + no SMTP is in place.

    • [ ] 5.12 — Apply PG-13 cert filter to cron reel-poster refresh. cron/index.ts populates landing_reel_posters without a certification filter — the primary reel-poster DB population path bypasses the guard added in 3.1/5.3. When implementing the cron reel-refresh handler (currently TODO in cron/index.ts), apply DISCOVER_CERT_PARAMS or fetchAndFilterByCert before writing to the table. Do not ship the cron refresh without this. Dependency: cron wiring in task 5.2. PINNED_REEL_POSTERS entries are exempt from this cert check — their tmdb_ids must be matched against the pin list and skipped before the filter runs. The backfill sweep that clears stale pre-filter rows must also exempt pinned entries. See Section 9b.

    • [ ] 5.13 — Cert-gate the trailer endpoint. /api/tmdb/movie/[id]/videos is not cert-checked — trailers are reachable by TMDB ID for titles that would be blocked by the content filter. Add isMovieAllowedByCert check (fetch movie details + validate) before returning video results, or 404 if the parent title is disallowed.


    Phase 6: Trailer URL and Metadata Refresh (April 27 - May 3, 2026) — Post-MVP

    • 6.1 — Implement background trailer URL refresh job in Node.js cron container: query movies where trailer_url IS NULL (bi-weekly cadence); fetch trailer URL from TMDB via server proxy for each; validate against domain allowlist; update trailer_url and trailer_url_refreshed_at in DB. NOTE: this scope (null-only) should be reassessed post-launch to also refresh stale/aging URLs after a certain age threshold.
    • 6.2 — Configure Node.js cron container scheduling; confirm jobs run reliably in production
    • 6.3 — Add monitoring/logging for refresh failures (e.g., TMDB returned no trailer) so missing URLs can be investigated
    • 6.4 — Implement monthly TMDB metadata refresh job: query movies where metadata_refreshed_at is older than 30 days (or NULL); refresh title, poster_path, genres, year from TMDB; update metadata_refreshed_at. Ensures TMDB ToS compliance for cached data freshness.

    Phase 7: Master Admin (May 4-17, 2026) — Post-MVP

    • 7.1 — Document Master Admin setup: MASTER_ADMIN_USERNAME and MASTER_ADMIN_TOTP_SECRET must be set as environment variables before first use; provide instructions for generating and enrolling the TOTP secret in an authenticator app; document that TOTP secret rotation requires redeployment
    • 7.2 — Build /admin login route: username + TOTP code input, no password-only fallback; reads credentials from environment variables
    • 7.3 — Implement server-side TOTP verification (otplib); issue admin session via iron-session v8 (encrypted HttpOnly cookie, Secure, SameSite=Strict, 8-hour expiry); use v8 README directly — v7 patterns are incompatible
    • 7.4 — Build Master Admin dashboard UI
    • 7.5 — Implement list search and deletion (by name or ID, with confirmation)
    • 7.6 — Implement user search and deletion (by display name or ID, with confirmation); deletion must remove both public.users row AND auth.users record (via supabase.auth.admin.deleteUser() using service role key)
    • 7.7 — Protect all /admin routes; redirect to login if no valid admin session
    • 7.8 — End-to-end TOTP test with a real authenticator app (Google Authenticator, Authy)

    Phase 8: PWA and Accessibility (May 18-31, 2026) — Post-MVP

    • 8.1 — Add PWA manifest and service worker via @serwist/next for home screen installation; requires authoring app/sw.ts, tsconfig.worker.json (WebWorker lib), and public/manifest.json with required fields and icon sizes (192x192, 512x512, maskable variants); budget a half-day
    • 8.2 — Implement offline graceful degradation: cached list data via TanStack Query persistQueryClient (IndexedDB; requires @tanstack/react-query-persist-client, @tanstack/query-async-storage-persister, idb-keyval), disable write actions with message; do not queue offline writes; budget 2-4 hours including serialization testing
    • 8.3 — Accessibility pass: contrast ratios, tap target sizes (min 44x44px), aria labels on icon buttons; modal keyboard navigation and focus management (ESC to close, focus trap on open, focus return on close); verify alt text and aria-live regions added in Phases 3-5 are correct; verify RollAnnouncer timing aligns with carousel settle (~3350ms)
    • 8.4 — Performance tuning: poster lazy loading verification, infinite scroll performance, search debounce, animation on low-end devices; consider virtualized scrolling (@tanstack/react-virtual) if grid performance degrades with large lists

    Phase 9: QA and Cross-Device Testing (June 1-21, 2026) — Post-MVP

    • 9.1 — Cross-device testing: iOS Safari, Android Chrome, desktop Chrome/Firefox
    • 9.2 — Group flow end-to-end: create group, join from second device, add movies, roll, mark watched
    • 9.3 — Real-time sync test: verify live updates across two active sessions; verify subscription cleanup on navigation
    • 9.4 — Recovery code test: create account, simulate new device, recover identity; verify rate limiting on claim endpoint; verify code invalidation after use. NOTE: Task 1.6 browser verification completed 2026-05-06 — both escalation triggers (auth.identities row, GoTrue #2013) cleared. This task is unblocked.
    • 9.5 — List Admin permissions test: confirm admin-only actions are blocked for regular members via RLS
    • 9.6 — Admin self-removal test: trigger "Leave this list" as admin with other members present; confirm shake-to-arm arms on first click; confirm inline successor picker appears on second click; confirm transfer+leave runs atomically and router.push("/") fires; confirm list is NOT deleted; confirm "Delete the list" action does NOT show the successor picker
    • 9.7 — Master Admin test: TOTP login, iron-session v8 cookie validation, list/user search and deletion (verify both public.users and auth.users records removed), session protection and expiry
    • 9.8 — TMDB rate limit check: confirm debounce and TanStack Query caching stay within rate limits; verify TMDB API key is not exposed in client-side code or network requests; verify include_adult=false and PG-13 cert filter applied on all relevant calls
    • 9.9 — Landing page test: slot-machine reel animation plays using automatically fetched TMDB posters and lands on a valid movie result; result TeaserCard emerges in carousel center with gold glow, stays settled until re-roll; "i" button opens MoreInfoModal with plot, Add to list (→ /login), and Watch Trailer; roll buttons work without login; 3-step demo alignment renders correctly on all screen sizes; TMDB attribution footer and legal footer visible on all pages; privacy policy accessible and contains all required sections
    • 9.10 — Home page test: returning user lands on home page (not landing page); list cards display correctly in single-column centered grid; empty-state path shows centered stacked large CTAs when user has no lists; top create/join row hidden when no lists; RollSection does not render until both groups and movies exist; RollSection buttons stack correctly on mobile and sit side-by-side on desktop; cross-list roll plays ListRollCarousel and result appears on the home page (no navigation into a list); roll result card is a full-width clickable button that opens ListMoreInfoModal
    • 9.11 — Movie info modal test: tapping a poster opens ListMoreInfoModal; modal renders poster, title, added-by, genre tags, TMDB overview (fetched live), Watched It (three-state shake-to-arm), Trailer (opens URL), Delete (shake-to-arm, only when onDelete supplied); ESC and click-outside close; focus trap and restoration work; arming Watched disarms Delete and vice versa; 4s auto-disarm fires; roll-result modal does NOT show Delete button; modal renders correctly above carousel (portal escape works)
    • 9.12 — Background job test: confirm Node.js cron container runs trailer URL refresh (null-only) and metadata refresh correctly; verify URL domain validation; verify cert filter is applied to reel-poster refresh (task 5.12 complete); verify no adult/non-PG-13 content in reel posters
    • 9.13 — Security headers test: verify CSP, HSTS, X-Frame-Options are correctly applied on all routes; verify CSP uses self-hosted URLs (not *.supabase.co)
    • 9.14 — RLS test: verify unauthorized access attempts are blocked at the database level (attempt direct Supabase queries outside group membership); verify WITH CHECK prevents added_by spoofing and role escalation
    • 9.15 — Invite code rate limiting test: verify brute-force protection on join endpoint
    • 9.16 — Run Playwright E2E tests against Docker production stack for critical paths: onboarding flow, add movie + real-time sync, roll the dice
    • 9.17 — Data retention test: verify inactive account cleanup job correctly identifies and deletes accounts inactive for 12+ months; verify orphan group handling (admin transfer or cascade delete); verify auth.users record removal
    • 9.18 — Docker security test: verify Kong/Postgres ports not accessible from host; verify Studio not publicly accessible; verify all default Supabase secrets have been replaced; verify Caddy TLS certificates persist across container restart
    • 9.19 — Backup test: verify pg_dump backup runs daily; test restore procedure
    • 9.20 — Sign-out test: verify sign-out clears the Supabase session cookie and redirects to landing page; verify signed-out user cannot access authenticated routes; verify sign-out control is distinct from /api/admin/logout; confirm no anonymous-account warning dialog appears (per 5.9 decision). NOTE: manual click-through verification was performed against the dev server in the 5.9 implementation round; no automated Playwright e2e test was added. This task remains open until automated coverage is in place.
    • 9.21 — Realtime capacity review: before launch, consult research/TECHFILE.md "Realtime Scaling Limits" section and assess whether projected concurrent users approach any documented capacity trigger (CPU >70%, WAL growth, p95 broadcast latency). No action required if projections are well below thresholds; document the assessment outcome here.
    • 9.22 — Terms page: verify /terms does not exist and no dead links to it appear anywhere in the UI. When terms content is authored, add the route and uncomment/add the LegalFooter link. Do not ship a stub.
    • 9.23 — Content policy end-to-end test: verify PG-13 cert filter is applied on /discover, /popular, /reel-posters, /search, and /movie/[id]; verify trailer endpoint (/movie/[id]/videos) is cert-gated (task 5.13 complete); verify no unallowlisted titles appear in any roll pool or search results.

    Phase 10: Launch (June 22 - July 5, 2026) — Full Feature Complete

    • 10.1 — Final Docker production deployment and full smoke test
    • 10.2 — Confirm PWA install flows work on iOS and Android
    • 10.3 — Verify Sentry error monitoring is capturing errors correctly (with UUID sanitization); review and resolve any outstanding issues
    • 10.4 — Soft launch: share with initial test group, gather feedback
    • 10.5 — Address any launch-blocking bugs found during soft launch
    • 10.6 — Promote HSTS max-age to production value (2 years)
    • 10.7 — Full feature complete sign-off by July 5, 2026

    9. Success Criteria

    • A group of 2+ people can go from zero to a shared movie list in under 2 minutes
    • Rolling the dice produces a result in under 5 seconds (including animation)
    • Real-time list updates appear on all connected devices within 3 seconds
    • Genre Roll correctly filters by at least 8 of 10 emotion categories
    • Recovery code flow successfully restores a user's identity on a new device
    • Recovery code claim endpoint blocks after 5 failed attempts per IP
    • Returning users land on their home page (not the landing page) when a valid Supabase session exists
    • Logged-in home page correctly shows all of the user's lists as cards with accurate movie counts
    • Cross-list roll on the home page draws from all user lists combined and displays the result as a standalone teaser card on the home page without navigating into any list
    • Roll result card in ListRollCarousel is a full clickable button that opens ListMoreInfoModal; "i" glyph is decorative and aria-hidden
    • Home page empty state: centered stacked large CTAs (Create List + Join a List) are the only visible entry point when user has no lists; top create/join row is hidden; RollSection does not render until groups and movies both exist
    • Admin "Leave this list" with other members present uses shake-to-arm; second click reveals inline successor picker; transfer+leave runs atomically; original admin is removed after transfer; list is not deleted; direct "Delete the list" action does not trigger the successor picker
    • Master Admin can log in with TOTP (credentials from environment variables) and delete any list or user (both public.users and auth.users records); session expires after 8 hours
    • Trailer URL refresh job processes only movies where trailer_url is null (bi-weekly cadence)
    • Monthly metadata refresh job keeps cached TMDB data current (TMDB ToS compliance)
    • Landing page roll result TeaserCard emerges in the carousel center with gold glow, stays settled until re-roll, and the "i" info button opens a MoreInfoModal with plot, Add to list (→ /login), and Watch Trailer
    • Landing page roll buttons work without any login or account creation
    • App installs to mobile home screen and functions as a PWA
    • Tapping a movie poster opens ListMoreInfoModal; modal shows TMDB overview, Watched It (three-state shake-to-arm), Trailer, and Delete (when applicable); portal renders correctly above carousel
    • ListMoreInfoModal is fully operable via keyboard (ESC to close, focus trap, focus restoration)
    • Delete two-tap confirmation does not trigger on a single tap; arming watched disarms delete and vice versa
    • Watched state toggle (green-circle checkmark overlay + button color) updates in real time across all group members' screens
    • No personal data beyond display name is stored or transmitted
    • TMDB API key is not exposed in any client-side code or network request
    • TMDB attribution and legal footer (© MovieDice, privacy links, cookie notice) are visible on all pages
    • Legal footer does not include a Terms link until /terms content is authored and the route exists
    • Adult content policy: no title above PG-13 appears in any roll pool, search result, or direct movie fetch; cert filter applied belt-and-suspenders across /discover, /popular, /reel-posters, /search, and /movie/[id]; cron reel-poster refresh is cert-filtered; trailer endpoint is cert-gated; PINNED_REEL_POSTERS entries are the sole sanctioned exception, scoped to the landing carousel editorial surface only
    • RLS policies prevent unauthorized data access at the database level; WITH CHECK prevents column spoofing
    • All animations respect prefers-reduced-motion preference; search bar pulse glow also respects prefers-reduced-motion
    • Inactive accounts are automatically deleted after 12 months with correct orphan group handling
    • Security headers (CSP with self-hosted URLs, HSTS, X-Frame-Options) are correctly applied
    • Invite code join endpoint is rate-limited against brute force
    • Docker deployment runs with non-root user, health check, and no exposed internal ports
    • All default Supabase secrets have been replaced before production deployment
    • Supabase Studio is not publicly accessible
    • Database backups run daily with successful restore verification
    • Sentry error events do not contain user UUIDs
    • Privacy policy contains all required GDPR/CCPA sections
    • User-facing sign-out clears the session cookie and redirects to the landing page; signed-out users cannot access authenticated routes
    • Create-group flow redirects directly to /list/{id} on success; join code visible prominently in list header (large mono text with "Invite Friends to Add Movies!" eyebrow); no in-form invite-code panel
    • RollBar hidden when movie list is empty

    9a. Scope Clarifications — Recovery Flow Refactor (2026-05-03)

    Decisions made during the Task 1.6 synthetic-identity-at-generate implementation. Recorded here to prevent re-litigation.

    Decided NOT to do — rationale:

    Decision Rationale
    Caddy rate-limit on /auth/v1/token Unnecessary — Kong/GoTrue are not externally exposed; existing claim 5/15min per-IP rate limit on /api/auth/recovery/claim is sufficient
    Backfill synthetic identity onto existing anon users Pre-launch only: all users will be dropped (TRUNCATE auth.users CASCADE + public tables) before first real user. No backfill needed.
    Recovery-code rotation endpoint Out of scope (deferred). Reload of /recovery regenerates the code server-side (server overwrites users.recovery_code). A dedicated rotation API is not planned for MVP.
    Post-claim is_anonymous re-anonymization Rejected — would re-introduce the runtime GoTrue conversion bug that the synthetic-identity architecture was designed to sidestep. Claimed users remain non-anonymous in GoTrue; this is intentional.

    Known GoTrue constraint pinned in docker-compose.yml:

    GoTrue v2.170.0 is pinned in docker-compose.yml (comment references supabase/auth#2013). This version is required to avoid a residual bug where signInWithPassword with a synthetic email may not create an auth.identities row correctly. Do not upgrade GoTrue without verifying the #2013 fix is included and running the recovery flow end-to-end.

    9b. Scope Clarifications — Adult Content Policy (2026-05-21)

    Locked decisions. Recorded to prevent re-litigation.

    Policy = PG-13-or-better via certification allowlist, not adult:false alone.

    adult:false on TMDB is unreliable (titles self-classify; the field does not map to any rating authority). The certification allowlist in src/lib/tmdb/certification.ts is the authoritative gate. STRICT mode rejects titles with no recognized country cert — this is intentional; silence is treated as unknown, not safe.

    Belt-and-suspenders is intentional. Discover/popular/reel-posters use query-time cert params. Search post-filters because the /search/movie TMDB endpoint does not support cert params — post-filtering is the only option. /movie/[id] is cert-gated as a last defense for direct ID lookups. All three layers are required; removing any one of them reopens a vector.

    Known gaps are logged as blocking tasks (5.12, 5.13). Do not close those tasks or ship cron reel-refresh / trailer-by-ID without addressing them.

    PINNED_REEL_POSTERS is the single sanctioned bypass of the cert filter (locked 2026-05-21).

    The pin tmdb_id: 615 (The Passion of the Christ, R-rated US) stays in PINNED_REEL_POSTERS as an explicit policy override. This decision is the user's call and is intentional content curation.

    Implications locked here:

    • The cert filter is the default for all discoverable/searchable surfaces: /api/tmdb/{search,popular,discover,reel-posters fallback,movie/[id],movie/[id]/videos} and the cron's TMDB-sourced ingestion.
    • PINNED_REEL_POSTERS (defined in src/app/api/tmdb/reel-posters/route.ts and mirrored in cron/index.ts) is the only mechanism through which a title outside the cert window may appear on the landing carousel. It is an editorial surface, not a content-policy exception to be generalized. Each entry added to that list is a deliberate policy override and must be reviewed as such.
    • The cron reel-refresh handler (task 5.12) must skip the cert check for any tmdb_id that matches PINNED_REEL_POSTERS. A backfill sweep will clear stale pre-filter rows from landing_reel_posters, but pinned entries are exempt from that sweep too.
    • This bypass is carousel-surface only. Adding a route-layer override to the trailer endpoint or info modal is out of scope. PINNED_REEL_POSTERS entries appear in the carousel; clicking a pinned poster opens ListMoreInfoModal, which calls /api/tmdb/movie/[id] — that endpoint correctly cert-gates and will 404 for R-rated titles. The UX consequence (pin visible in carousel, modal 404s) is a deliberate policy compromise: editorial pin is visible, but discoverability stops at the carousel.
    • Do not expand the bypass to search, /movie/[id], or the trailer endpoint. Those surfaces remain fully cert-gated with no pin exemption.

    9c. Scope Clarifications — Legal Footer (2026-05-21)

    Locked decisions. Recorded to prevent re-litigation.

    Footer reflects ACTUAL data practice only. MovieDice collects no email, phone, or real identity. The footer says so. Adding a mention of data we do not collect would be inaccurate and creates legal surface area unnecessarily.

    No /terms stub. A Terms of Service page that exists but is empty or placeholder is worse than no page: it can be cited as an implicit agreement. Do not create the route until terms content is finalized and reviewed. The LegalFooter Terms link is intentionally absent.

    Trust boundary unchanged. The legal footer ships inside TMDBFooter, which is mounted at the layout level. No per-page footer logic. No server-side personalization. This is a static component.

    10. Reference: Emotion-to-Genre Mapping

    Static mapping used by Genre Roll to translate emotion keywords into TMDB genre IDs. Implemented as a static TypeScript constant. Normalize input to lowercase and tokenize on spaces and commas before matching.

    Emotion Keywords Primary Genres Secondary Genres
    happy, cheerful, upbeat, fun Comedy, Animation, Family Adventure, Musical
    sad, emotional, cry, tearjerker Drama, Romance War, Biography
    excited, hyped, energetic, pumped Action, Adventure Science Fiction, Thriller
    scared, tense, nervous, creepy Horror, Thriller Mystery
    calm, relaxed, chill, cozy Documentary, Drama Animation
    romantic, lovey, date night Romance, Comedy Drama
    thoughtful, reflective, deep Documentary, Drama History, Biography
    funny, silly, goofy, laugh Comedy, Animation Family
    dark, gritty, intense, serious Crime, Thriller Drama, War
    nostalgic, classic, retro (any genre, filtered to release year < 2000)

    Union all matched genre IDs from primary and secondary columns and filter the movie pool. If no tokens match any keyword, notify user with "No matches — showing full list" and proceed with the unfiltered pool.

    11. Extra Features

    Features added after initial scope. Complete current Implementation Plan progress before starting these.

    Feature Description Added On Rationale
    Compact list/grid toggle Toggle between poster grid and a compact list layout (title, year, metadata per row) 2026-04-05 Deferred from MVP — user unfamiliar with it at scoping; low priority relative to core flows
    Self-service account deletion User-facing account deletion flow with cascading deletes (wrapped in transaction), ownership transfer for administered groups (auto-transfer to longest-tenured member), anonymization of added_by references (ON DELETE SET NULL), and removal of auth.users record via supabase.auth.admin.deleteUser() 2026-04-06 GDPR Article 17 (Right to Erasure); MVP relies on Master Admin deletion on request as interim

    12. Change Log

    Date Summary
    2026-05-23 UI polish follow-ups (2.3, 3.2 — user-validated). Home page Create/Join equal-width pair: sm:flex-1 baked into all three JoinListButton variants; wrapper <div> indirection removed from home/page.tsx; collapsed shape mirrors roll-bar buttons. Roll buttons correctly hide when all lists have zero movies while Create/Join row remains — Create/Join lifted out of RollSection into home/page.tsx (rendered on hasGroups independent of movie count); RollSection no longer needs useUserGroups. Search bar centering: pl-20 added to balance pr-20 so placeholder lands dead-center. pulse-glow keyframes rewritten as two-layer gold halo (rgba(250,204,21,…) matching roll-teaser tone), visible in both light and dark modes; prefers-reduced-motion opt-out preserved. No new task IDs; all changes are polish on the 2026-05-21 batch.
    2026-05-21 Adult content hardening (3.1, 5.12-new, 5.13-new, 9.22-new, 9.23-new, Section 7 TMDB Proxy, Section 9b): PG-13 cert allowlist via certification.ts/discover//popular//reel-posters use DISCOVER_CERT_PARAMS, /search post-filters via fetchAndFilterByCert, /movie/[id] 404s non-allowlisted. Roll result card (4.3): ListTeaserCard now full <button>, "i" glyph aria-hidden. RollBar hidden when empty (3.4, 5.5, Section 5 Movie Card Grid View). Search bar restyle (3.2, Section 6): max-w-xl, bold placeholder, pulse-glow animation. List header join code prominence (2.4, Section 5 List Page Header): large mono text-3xl sm:text-4xl, "Invite Friends to Add Movies!" eyebrow. Home page restructure (2.3, Section 4, Section 5 Logged-In Home Page, Section 9): button hierarchy locked — no-lists empty state = centered stacked CTAs; RollSection gated on groups+movies; roll buttons match RollBar style. List settings cleanup (2.4, Section 5 List Admin Actions): 3-col header, "Rename List", inline code actions, text bump. TMDB attribution sitewide (5.1, Section 7 TMDB Attribution): TMDBFooter + LegalFooter in all 4 segment layouts; inline /list/[id] duplicate removed. Legal footer shipped (Section 4, Section 7 Privacy, 9.9, 9.22-new, Section 9c): legal-footer.tsx in TMDBFooter; /privacy links, CCPA/GDPR anchors, cookie notice; no Terms link; locked decisions in 9c. New tasks 5.12 (cron cert gap), 5.13 (trailer endpoint cert gate), 9.22 (terms page check), 9.23 (content policy e2e test). Scope clarification sections 9b, 9c added. Out-of-scope entry added for /terms deferral. PINNED_REEL_POSTERS policy override locked (Section 4 adult content row, Section 7 TMDB Proxy, task 5.2, task 5.12, Section 9b): tmdb_id 615 (The Passion of the Christ, R-rated US) stays in carousel as intentional editorial curation; PINNED_REEL_POSTERS is the sole sanctioned bypass of the cert filter, scoped to the landing carousel only; cron cert check and backfill sweep both exempt pinned entries; clicking a pinned poster 404s in the info modal (deliberate UX/policy compromise).
    2026-05-20 Added task 5.10 (completed): dev-server predev orchestration, Kong 2.8.1 env-var interpolation fix, GoTrue DISABLE_SIGNUP fix, Kong auth denylist. Added sub-note to 5.9: trust-boundary fix removing supabase.auth.signOut(). Added open bug note to 1.7: Dockerfile npm ci failure. Added task 5.11 (open): bypass-Kong bootstrap refactor.
    2026-05-08 Added task 9.21 — Realtime capacity review referencing research/TECHFILE.md "Realtime Scaling Limits" section (documented 2026-05-08, commit 8444d3d). No content duplicated; task is a pre-launch assessment gate only.
    2026-05-07 Walkthrough audit: marked complete — 1.1–1.5, 1.8, 1.9, 2.1–2.3, 3.1–3.3, 3.5, 5.5. Kept open — 1.7 (production deploy pending; code done locally), 5.8 (no server deploy yet). Added known bug to 3.8: real-time UPDATE events for watched state don't propagate to other clients. Added partial gap to 5.6: offline search shows false-empty instead of error. Sub-todo on 1.1: pre-push hook not yet wired. Phase 4 and Phases 6–10 unchanged.