Bläddra i källkod

[Phase 3/5] UI batch + RLS recursion fix

Fixes movies UPDATE policy 42P17 recursion (00005), updates create-group
and list detail flows, and refreshes scope/CLAUDE notes.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
User 1 månad sedan
förälder
incheckning
7382c5e411

+ 2 - 2
CLAUDE.md

@@ -35,7 +35,7 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 - `movies.metadata_refreshed_at` — for monthly TMDB metadata refresh (post-MVP).
 - Invite codes: WORD-WORD format (2,000+ words, 3-8 chars, offensive terms filtered, case-insensitive).
 - `admin_sessions` — iron-session v8 encrypted cookies, no DB table.
-- **RLS on all tables** with `WITH CHECK` clauses. `movies` INSERT enforces `added_by = auth.uid()`. `group_members` UPDATE prevents role escalation. Group join is server-side (service role key).
+- **RLS on all tables** with `WITH CHECK` clauses. `movies` INSERT enforces `added_by = auth.uid()`. `movies` UPDATE policy MUST NOT self-reference `public.movies` in subqueries (recursion 42P17) — `added_by` immutability is enforced by `movies_added_by_immutable` trigger (00005). `group_members` UPDATE prevents role escalation. Group join is server-side (service role key).
 - **Migrations:** `supabase migration new` only. No ad-hoc SQL in production.
 
 ## Frontend (Next.js App Router)
@@ -51,7 +51,7 @@ Tables: `users`, `groups`, `group_members`, `movies`, `landing_reel_posters`
 - Landing roll result emerges in carousel center: snap math lands a poster gap exactly at viewport center, posters spread ±`SPREAD_AMOUNT`, card pops in. Card stays settled until next roll (no auto-resume).
 - Modals descended from `animate-emerge` (or any transformed ancestor) MUST `createPortal` to `document.body` — `transform: scale(1)` from `fill-mode: both` establishes a containing block and clamps `fixed inset-0` to the ancestor's box.
 - **Terminology**: UI says "List", code/DB/routes say "group" (legacy). "Create List" button → `/create-group`; list detail → `/list/[id]` (reads `groups` table); list settings → `/list/[id]/settings` (mounts `SettingsPanel`). Don't add a `/create` route — point new "Create List" CTAs at `/create-group`.
-- Create-list form `router.push`es to `/list/{id}` on success (no in-form invite-code panel). List header is a centered vertical stack: name (`text-2xl sm:text-3xl`), uppercase "JOIN CODE" eyebrow + mono chip, settings cog. Destructive actions (SettingsPanel + per-movie Watched/Delete in `ListMoreInfoModal`) use shake-to-arm (`animate-shake`, 4s auto-disarm) — no `window.confirm`. Admin delete with other members shows an inline successor picker; selection runs transfer + leave atomically. Solo-admin delete and leave both `router.push("/")`.
+- Create-list form `router.push`es to `/list/{id}` on success (no in-form invite-code panel). List header is a 3-col grid: back arrow (→ `/home`) left, centered name (`text-2xl sm:text-3xl`) + "JOIN CODE" eyebrow + mono chip, settings cog right. `/create-group` mirrors the back-arrow + centered title pattern. Destructive actions (SettingsPanel + per-movie Watched/Delete in `ListMoreInfoModal`) use shake-to-arm (`animate-shake`, 4s auto-disarm) — no `window.confirm`. Admin delete with other members shows an inline successor picker; selection runs transfer + leave atomically. Solo-admin delete and leave both `router.push("/")`.
 - List grid (`/home`) and movie cards: `PosterCard` is a clickable button (whole-card target) that opens `ListMoreInfoModal` — no ExpandedPanel. Watched movies show a green-circle checkmark badge top-left; added-by avatar dot top-right; decorative "i" glyph bottom-right. Both list-page and `/home` rolls render `ListRollCarousel` (horizontal poster strip, dice-emerge entrance → spin → snap-to-gap → ±110px spread + emerge teaser w/ gold glow). `movies` table doesn't store TMDB overview — `ListMoreInfoModal` fetches `/api/tmdb/movie/[id]` on open.
 
 ## Auth

+ 14 - 6
PROJECT_SCOPE.md

@@ -266,10 +266,11 @@ Button layout:
 
 ```
 SHIPPED (2026-05-06): src/app/(app)/list/[id]/page.tsx
-- Centered vertical stack (both mobile and desktop)
-- List name: text-2xl sm:text-3xl font-bold
-- Uppercase tracked "JOIN CODE" eyebrow with mono chip (bg-foreground/10) showing WORD-WORD code
-- Settings cog below the join code
+- 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)
 ```
 
 ### List Admin Actions
@@ -455,7 +456,7 @@ RLS must be enabled on ALL tables with explicit policies — no permissive catch
 - **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`
+- **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.
@@ -529,6 +530,9 @@ Configure HTTP security headers at the Caddy reverse proxy level (not in next.co
 
 - [ ] 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)
 - [ ] 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.
@@ -538,13 +542,14 @@ Configure HTTP security headers at the Caddy reverse proxy level (not in next.co
 
 - [ ] 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
 - [ ] 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.
 
 ### 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
-- [x] 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.
+- [x] 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. 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.
 - [x] 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.
 - [x] 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.
 
@@ -570,6 +575,9 @@ Configure HTTP security headers at the Caddy reverse proxy level (not in next.co
 - [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.
+
 - [ ] 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

+ 28 - 4
src/app/(app)/create-group/page.tsx

@@ -1,15 +1,39 @@
 import { CreateGroupForm } from "@/components/groups/create-group-form";
 
 export const metadata = {
-  title: "Create Group - MovieDice",
+  title: "Create List - MovieDice",
 };
 
 export default function CreateGroupPage() {
   return (
     <main className="mx-auto max-w-md px-4 py-8">
-      <h1 className="text-2xl font-bold mb-6">Create a Group</h1>
-      <p className="text-sm text-gray-600 dark:text-gray-400 mb-6">
-        Create a group and share the invite code with friends to start building your movie list
+      <div className="mb-6 grid grid-cols-[auto_1fr_auto] items-center gap-3">
+        <a
+          href="/home"
+          className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
+          aria-label="Back to home"
+        >
+          <svg
+            xmlns="http://www.w3.org/2000/svg"
+            width="20"
+            height="20"
+            viewBox="0 0 24 24"
+            fill="none"
+            stroke="currentColor"
+            strokeWidth="2"
+            strokeLinecap="round"
+            strokeLinejoin="round"
+            aria-hidden="true"
+          >
+            <path d="M19 12H5" />
+            <path d="m12 19-7-7 7-7" />
+          </svg>
+        </a>
+        <h1 className="text-center text-2xl font-bold">Create a List</h1>
+        <span className="w-9" aria-hidden="true" />
+      </div>
+      <p className="mb-6 text-sm text-gray-600 dark:text-gray-400">
+        Create a list and share the join code with friends to start building your movie list
         together.
       </p>
       <CreateGroupForm />

+ 32 - 9
src/app/(app)/list/[id]/page.tsx

@@ -41,18 +41,41 @@ export default async function ListPage({ params }: ListPageProps) {
     <div className="flex min-h-screen flex-col">
       {/* Header */}
       <header className="sticky top-0 z-10 border-b border-white/10 bg-background/80 backdrop-blur-sm">
-        <div className="mx-auto flex max-w-5xl flex-col items-center gap-2 px-4 py-4 text-center">
-          <h1 className="max-w-full truncate text-2xl font-bold sm:text-3xl">{group.name}</h1>
-          <p className="text-[11px] uppercase tracking-[0.18em] text-foreground/45">
-            Join code{" "}
-            <span className="ml-1 rounded bg-foreground/10 px-2 py-0.5 font-mono text-xs tracking-normal text-foreground/80">
-              {group.invite_code}
-            </span>
-          </p>
+        <div className="mx-auto grid max-w-5xl grid-cols-[auto_1fr_auto] items-center gap-3 px-4 py-4">
+          <a
+            href="/home"
+            className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
+            aria-label="Back to home"
+          >
+            <svg
+              xmlns="http://www.w3.org/2000/svg"
+              width="20"
+              height="20"
+              viewBox="0 0 24 24"
+              fill="none"
+              stroke="currentColor"
+              strokeWidth="2"
+              strokeLinecap="round"
+              strokeLinejoin="round"
+              aria-hidden="true"
+            >
+              <path d="M19 12H5" />
+              <path d="m12 19-7-7 7-7" />
+            </svg>
+          </a>
+          <div className="flex min-w-0 flex-col items-center gap-1 text-center">
+            <h1 className="max-w-full truncate text-2xl font-bold sm:text-3xl">{group.name}</h1>
+            <p className="text-[11px] uppercase tracking-[0.18em] text-foreground/45">
+              Join code{" "}
+              <span className="ml-1 rounded bg-foreground/10 px-2 py-0.5 font-mono text-xs tracking-normal text-foreground/80">
+                {group.invite_code}
+              </span>
+            </p>
+          </div>
           <a
             href={`/list/${group.id}/settings`}
             className="rounded-lg p-2 text-foreground/60 transition-colors hover:bg-white/5 hover:text-foreground"
-            aria-label="Group settings"
+            aria-label="List settings"
           >
             <svg
               xmlns="http://www.w3.org/2000/svg"

+ 2 - 2
src/components/groups/create-group-form.tsx

@@ -68,9 +68,9 @@ export function CreateGroupForm() {
       <button
         type="submit"
         disabled={createGroup.isPending || createGroup.isSuccess || !name.trim()}
-        className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:opacity-50 disabled:cursor-not-allowed"
+        className="mx-auto rounded-md bg-blue-600 px-6 py-2 text-sm font-medium text-white hover:bg-blue-700 disabled:cursor-not-allowed disabled:opacity-50"
       >
-        {createGroup.isPending ? "Creating..." : "Create Group"}
+        {createGroup.isPending ? "Creating..." : "Create List"}
       </button>
     </form>
   );

+ 46 - 0
supabase/migrations/00005_movies_update_recursion_fix.sql

@@ -0,0 +1,46 @@
+-- Fix infinite recursion in movies UPDATE policy.
+--
+-- The original WITH CHECK clause referenced `public.movies` in a subquery
+-- (to enforce that added_by cannot change). Selecting from movies while the
+-- movies_update policy is being evaluated triggers RLS recursion (42P17).
+--
+-- Replace the self-referencing check with a BEFORE UPDATE trigger that
+-- forbids mutating added_by at the row level. Group-membership check stays
+-- in the policy.
+
+DROP POLICY IF EXISTS movies_update ON public.movies;
+
+CREATE POLICY movies_update ON public.movies
+  FOR UPDATE USING (
+    EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = movies.group_id
+        AND group_members.user_id = auth.uid()
+    )
+  )
+  WITH CHECK (
+    EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = movies.group_id
+        AND group_members.user_id = auth.uid()
+    )
+  );
+
+CREATE OR REPLACE FUNCTION public.movies_prevent_added_by_change()
+RETURNS TRIGGER
+LANGUAGE plpgsql
+AS $$
+BEGIN
+  IF NEW.added_by IS DISTINCT FROM OLD.added_by THEN
+    RAISE EXCEPTION 'movies.added_by is immutable';
+  END IF;
+  RETURN NEW;
+END;
+$$;
+
+DROP TRIGGER IF EXISTS movies_added_by_immutable ON public.movies;
+
+CREATE TRIGGER movies_added_by_immutable
+  BEFORE UPDATE ON public.movies
+  FOR EACH ROW
+  EXECUTE FUNCTION public.movies_prevent_added_by_change();