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).
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:
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.
| 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. 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; results show below a separator from in-list results |
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 |
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)]) |
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 and replaces Login with Create List; Roll the Dice, Genre Roll, and Create List buttons grouped in RollSection (stacked full-width on mobile, side-by-side centered on desktop); 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) |
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 |
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 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 (public)/layout.tsx): TMDB attribution (logo + link + disclaimer),
privacy policy link
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)
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. The Login button is replaced — Roll, Genre Roll, and Create List are grouped in one
RollSection component (stacked full-width on mobile via flex-col items-stretch w-full;
side-by-side centered on desktop via sm:flex-row sm:justify-center). Create List button
is bg-blue-700 hover:bg-blue-600. Standalone Create List button in home/page.tsx removed.
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
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
3. Results appear in two sections:
- Top: movies already in the group's list (labeled "In Your List")
- Below separator: TMDB search results (server-side filtered by adult field)
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
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
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.
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 opens ListMoreInfoModal
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
- Home: Roll + Genre + Create List grouped in RollSection; flex-col items-stretch w-full on
mobile, sm:flex-row sm:justify-center on desktop
SHIPPED (2026-05-06): 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), uppercase tracked "JOIN CODE" eyebrow,
mono chip (bg-foreground/10) showing WORD-WORD invite code
- /create-group page mirrors back-arrow + centered-title pattern; button text changed to
"Create List" and centered (2026-05-06)
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 "Join code: WORD-WORD" — 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:
- Rename the list
- Delete the list (see deletion flow below)
- View member list with option to remove individual members
- Regenerate invite code (revokes old code)
- Display current invite code with copy-to-clipboard
3. Regular members see a settings menu with only: "Leave this list" option
(same shake-to-arm confirmation pattern as admin terminal actions)
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
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)
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.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).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.alt text on all poster images; aria-live="polite" regions for dynamic status messages (roll results, filter changes, watched state toggles, action confirmations)./api/tmdb/*) with include_adult=false. Cache recent search results via TanStack Query with explicit staleTime configuration.| 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 |
| 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 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 postersw500 — movie info modal (ListMoreInfoModal)loading="lazy" attribute on all poster <img> tagsalt text on all poster images (e.g., "Movie Title (Year) poster"); spinning reel posters use aria-hidden (decorative)next/image for locally-served assets only (logo, icons)sharp as an explicit production dependency for local asset optimizationTMDB 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." Implement as a site-wide footer component. Non-compliance risks API key revocation.
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. All calls must set include_adult=false and server-side filter results by the adult field as defense in depth.
users
signInAnonymously())groups
group_members
movies
overview (plot/description) is NOT stored. ListMoreInfoModal fetches it from TMDB per-open.landing_reel_posters
admin_sessions
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:
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)Supabase Realtime also respects RLS — subscriptions are authorized by the same policies.
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.
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.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.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: DENYX-Content-Type-Options: nosniffReferrer-Policy: strict-origin-when-cross-originStrict-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 confidentPermissions-PolicyContent-Security-Policy-Report-Only during development to identify violations without blockingnode: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)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_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 disabled127.0.0.1:3000 or remove from production docker-compose; access via SSH tunnel only. Only Caddy is exposed to the internet.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/api/health endpoint checking Supabase connectivity; used by Docker HEALTHCHECKnode: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 refreshpg_dump backup container in docker-compose running daily with 7-day retention; document restore procedure; test restore before launchmax-size: 10m, max-file: 5 to prevent disk exhaustion and bound GDPR log retentionTMDB_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 usernameMASTER_ADMIN_TOTP_SECRET — the TOTP secret (base32); configure this in your authenticator app (e.g., Google Authenticator, Authy) before first useIRON_SESSION_SECRET — 32+ character secret for iron-session cookie encryptionGOTRUE_EXTERNAL_ANONYMOUS_USERS_ENABLED=true — required for anonymous sign-in@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 failureMVP Deadline: April 26, 2026 Full Feature Complete: July 5, 2026
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.
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 newRLS 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.
@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 callssupabase.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 setf71cb76 + 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.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.
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 productionNOTE (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).
beforeSend to strip UUID path segments from error events; do not call Sentry.setUser() with user identifiersVERIFIED (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.
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.
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. UI POLISH (2026-05-06): List page header redesigned as 3-col grid (back arrow left, centered name + JOIN CODE eyebrow + mono invite chip, settings cog right); /create-group page header mirrors back-arrow + centered-title pattern, button text changed to "Create List" and centered./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 markdownBUG 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 ListMoreInfoModalsrc/components/movies/movie-search-panel.tsx — never wired upsrc/components/home/home-roll-teaser-card.tsx — home now uses ListRollCarouselsrc/components/dice/roll-animation.tsx — home and list both now use ListRollCarouselsrc/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.
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.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).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.filterByGenresAndEmotionsStructured (2026-04-14) — fixes genre-only rolls silently discarding the filter when movies.genres stores TMDB names, not numeric IDs.ListRollCarousel receives the filtered pool directly.src/app/(public)/page.tsx + (public)/layout.tsx. Duplicate stub src/app/page.tsx removed so (public)/page.tsx is no longer shadowed.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.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).
/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.VERIFIED (2026-05-07): User-confirmed adequate empty and loading states across tested views.
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.
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.
44ff76c→582a7fd→add8745): 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.
public.users row AND auth.users record (via supabase.auth.admin.deleteUser() using service role key)@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-daypersistQueryClient (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 testingRollAnnouncer timing aligns with carousel settle (~3350ms)@tanstack/react-virtual) if grid performance degrades with large lists/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.prefers-reduced-motion preferenceDecisions 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.
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.
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 |
| Date | Summary |
|---|---|
| 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. |