Explorar el Código

feat: add database schema, RLS policies, and seed data

Create complete Supabase database migrations for all 5 tables (users,
groups, group_members, movies, landing_reel_posters) with CHECK
constraints, foreign keys, and indexes. Add RLS policies enforcing
auth.uid()-based access control with WITH CHECK clauses to prevent
attribution spoofing and role escalation. Include dev seed data.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
User hace 2 meses
padre
commit
4bd7ecdf96

+ 10 - 0
supabase/config.toml

@@ -0,0 +1,10 @@
+[project]
+id = "moviedice"
+name = "MovieDice"
+
+[db]
+major_version = 15
+
+[db.seed]
+enabled = true
+sql_paths = ["./seed.sql"]

+ 88 - 0
supabase/migrations/00001_initial_schema.sql

@@ -0,0 +1,88 @@
+-- 00001_initial_schema.sql
+-- MovieDice: initial database schema
+-- Tables: users, groups, group_members, movies, landing_reel_posters
+
+-- =============================================================================
+-- users
+-- =============================================================================
+CREATE TABLE public.users (
+  id            uuid PRIMARY KEY,  -- maps to auth.users.id (Supabase Anonymous Sign-In)
+  display_name  text NOT NULL
+    CONSTRAINT users_display_name_length CHECK (char_length(display_name) BETWEEN 1 AND 30)
+    CONSTRAINT users_display_name_no_html CHECK (display_name !~ '[<>]')
+    CONSTRAINT users_display_name_no_control CHECK (display_name !~ '[\x00-\x1F\x7F]'),
+  avatar_color  text
+    CONSTRAINT users_avatar_color_hex CHECK (avatar_color IS NULL OR avatar_color ~ '^#[0-9a-fA-F]{6}$'),
+  recovery_code text,  -- Argon2id hashed, nullable, single-use
+  last_active_at timestamptz NOT NULL DEFAULT now(),
+  created_at     timestamptz NOT NULL DEFAULT now()
+);
+
+-- =============================================================================
+-- groups
+-- =============================================================================
+CREATE TABLE public.groups (
+  id          uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  name        text NOT NULL
+    CONSTRAINT groups_name_length CHECK (char_length(name) BETWEEN 1 AND 50)
+    CONSTRAINT groups_name_no_html CHECK (name !~ '[<>]')
+    CONSTRAINT groups_name_no_control CHECK (name !~ '[\x00-\x1F\x7F]'),
+  invite_code text NOT NULL UNIQUE,
+  created_by  uuid NOT NULL REFERENCES public.users(id),
+  created_at  timestamptz NOT NULL DEFAULT now()
+);
+
+-- Note: invite_code already indexed by the UNIQUE constraint
+
+-- =============================================================================
+-- group_members
+-- =============================================================================
+CREATE TABLE public.group_members (
+  group_id  uuid NOT NULL REFERENCES public.groups(id) ON DELETE CASCADE,
+  user_id   uuid NOT NULL REFERENCES public.users(id) ON DELETE CASCADE,
+  role      text NOT NULL DEFAULT 'member'
+    CONSTRAINT group_members_role_valid CHECK (role IN ('admin', 'member')),
+  joined_at timestamptz NOT NULL DEFAULT now(),
+  PRIMARY KEY (group_id, user_id)
+);
+
+CREATE INDEX idx_group_members_user_id ON public.group_members (user_id);
+-- Note: group_id already indexed as leading column of the composite PK
+
+-- =============================================================================
+-- movies
+-- =============================================================================
+CREATE TABLE public.movies (
+  id                    uuid PRIMARY KEY DEFAULT gen_random_uuid(),
+  group_id              uuid NOT NULL REFERENCES public.groups(id) ON DELETE CASCADE,
+  tmdb_id               integer NOT NULL,
+  title                 text NOT NULL,
+  year                  integer,
+  poster_path           text,
+  genres                text[],
+  trailer_url           text
+    CONSTRAINT movies_trailer_url_domain CHECK (
+      trailer_url IS NULL
+      OR trailer_url ~ '^https://(www\.)?(youtube\.com|themoviedb\.org|imdb\.com)/'
+    ),
+  trailer_url_refreshed_at timestamptz,
+  metadata_refreshed_at    timestamptz,
+  added_by              uuid REFERENCES public.users(id) ON DELETE SET NULL,
+  watched               boolean NOT NULL DEFAULT false,
+  watched_at            timestamptz,
+  added_at              timestamptz NOT NULL DEFAULT now()
+);
+
+CREATE INDEX idx_movies_group_id ON public.movies (group_id);
+CREATE INDEX idx_movies_added_by ON public.movies (added_by);
+
+-- =============================================================================
+-- landing_reel_posters
+-- =============================================================================
+CREATE TABLE public.landing_reel_posters (
+  id           serial PRIMARY KEY,
+  tmdb_id      integer NOT NULL,
+  poster_path  text NOT NULL,
+  title        text NOT NULL,
+  refreshed_at timestamptz NOT NULL DEFAULT now()
+);

+ 147 - 0
supabase/migrations/00002_rls_policies.sql

@@ -0,0 +1,147 @@
+-- 00002_rls_policies.sql
+-- MovieDice: Row Level Security policies for all tables
+
+-- =============================================================================
+-- Enable RLS on all tables
+-- =============================================================================
+ALTER TABLE public.users ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.groups ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.group_members ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.movies ENABLE ROW LEVEL SECURITY;
+ALTER TABLE public.landing_reel_posters ENABLE ROW LEVEL SECURITY;
+
+-- =============================================================================
+-- users: SELECT/UPDATE own row only
+-- =============================================================================
+CREATE POLICY users_select_own ON public.users
+  FOR SELECT USING (auth.uid() = id);
+
+CREATE POLICY users_update_own ON public.users
+  FOR UPDATE USING (auth.uid() = id)
+  WITH CHECK (auth.uid() = id);
+
+-- =============================================================================
+-- groups: SELECT only if user is a member
+-- =============================================================================
+CREATE POLICY groups_select_member ON public.groups
+  FOR SELECT USING (
+    EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = groups.id
+        AND group_members.user_id = auth.uid()
+    )
+  );
+
+-- =============================================================================
+-- group_members: complex policies
+-- =============================================================================
+
+-- SELECT: can see members of groups you belong to
+CREATE POLICY group_members_select ON public.group_members
+  FOR SELECT USING (
+    EXISTS (
+      SELECT 1 FROM public.group_members AS gm
+      WHERE gm.group_id = group_members.group_id
+        AND gm.user_id = auth.uid()
+    )
+  );
+
+-- INSERT: blocked for anon users (server-side via service role key only)
+-- No INSERT policy = denied by default with RLS enabled
+
+-- DELETE: admin of the group OR self (leaving)
+CREATE POLICY group_members_delete ON public.group_members
+  FOR DELETE USING (
+    group_members.user_id = auth.uid()
+    OR EXISTS (
+      SELECT 1 FROM public.group_members AS gm
+      WHERE gm.group_id = group_members.group_id
+        AND gm.user_id = auth.uid()
+        AND gm.role = 'admin'
+    )
+  );
+
+-- UPDATE: prevent role escalation
+-- Only admins of the group can update roles, and only they can set role to 'admin'
+CREATE POLICY group_members_update ON public.group_members
+  FOR UPDATE USING (
+    EXISTS (
+      SELECT 1 FROM public.group_members AS gm
+      WHERE gm.group_id = group_members.group_id
+        AND gm.user_id = auth.uid()
+        AND gm.role = 'admin'
+    )
+  )
+  WITH CHECK (
+    -- Only allow setting role to 'admin' if the current user is an admin of this group
+    role = 'member'
+    OR EXISTS (
+      SELECT 1 FROM public.group_members AS gm
+      WHERE gm.group_id = group_members.group_id
+        AND gm.user_id = auth.uid()
+        AND gm.role = 'admin'
+    )
+  );
+
+-- =============================================================================
+-- movies: full CRUD for group members
+-- =============================================================================
+
+-- SELECT: only if member of the owning group
+CREATE POLICY movies_select ON public.movies
+  FOR SELECT USING (
+    EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = movies.group_id
+        AND group_members.user_id = auth.uid()
+    )
+  );
+
+-- INSERT: member of group + added_by must be auth.uid()
+CREATE POLICY movies_insert ON public.movies
+  FOR INSERT WITH CHECK (
+    added_by = auth.uid()
+    AND EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = movies.group_id
+        AND group_members.user_id = auth.uid()
+    )
+  );
+
+-- UPDATE: member of group + cannot change added_by
+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 (
+    added_by IS NOT DISTINCT FROM (
+      SELECT m.added_by FROM public.movies m WHERE m.id = movies.id
+    )
+    AND EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = movies.group_id
+        AND group_members.user_id = auth.uid()
+    )
+  );
+
+-- DELETE: member of the owning group
+CREATE POLICY movies_delete ON public.movies
+  FOR DELETE USING (
+    EXISTS (
+      SELECT 1 FROM public.group_members
+      WHERE group_members.group_id = movies.group_id
+        AND group_members.user_id = auth.uid()
+    )
+  );
+
+-- =============================================================================
+-- landing_reel_posters: public read, no client write
+-- =============================================================================
+CREATE POLICY landing_reel_posters_select ON public.landing_reel_posters
+  FOR SELECT USING (true);
+
+-- No INSERT/UPDATE/DELETE policies = service role only (RLS bypassed by service role key)

+ 52 - 0
supabase/seed.sql

@@ -0,0 +1,52 @@
+-- seed.sql
+-- Dev seed data for MovieDice
+-- Note: In dev, these UUIDs simulate Supabase Auth UIDs from signInAnonymously()
+
+-- =============================================================================
+-- Users
+-- =============================================================================
+INSERT INTO public.users (id, display_name, avatar_color, last_active_at, created_at) VALUES
+  ('a1b2c3d4-0000-4000-8000-000000000001', 'Alice',  '#FF6B6B', now(), now()),
+  ('a1b2c3d4-0000-4000-8000-000000000002', 'Bob',    '#4ECDC4', now(), now()),
+  ('a1b2c3d4-0000-4000-8000-000000000003', 'Carol',  '#45B7D1', now(), now());
+
+-- =============================================================================
+-- Groups
+-- =============================================================================
+INSERT INTO public.groups (id, name, invite_code, created_by, created_at) VALUES
+  ('b1b2c3d4-0000-4000-8000-000000000001', 'Friday Night Movies', 'WOLF-MOON',   'a1b2c3d4-0000-4000-8000-000000000001', now()),
+  ('b1b2c3d4-0000-4000-8000-000000000002', 'Sci-Fi Club',         'STAR-GATE',   'a1b2c3d4-0000-4000-8000-000000000002', now());
+
+-- =============================================================================
+-- Group Members
+-- =============================================================================
+INSERT INTO public.group_members (group_id, user_id, role, joined_at) VALUES
+  -- Friday Night Movies: Alice (admin), Bob, Carol
+  ('b1b2c3d4-0000-4000-8000-000000000001', 'a1b2c3d4-0000-4000-8000-000000000001', 'admin',  now()),
+  ('b1b2c3d4-0000-4000-8000-000000000001', 'a1b2c3d4-0000-4000-8000-000000000002', 'member', now()),
+  ('b1b2c3d4-0000-4000-8000-000000000001', 'a1b2c3d4-0000-4000-8000-000000000003', 'member', now()),
+  -- Sci-Fi Club: Bob (admin), Carol
+  ('b1b2c3d4-0000-4000-8000-000000000002', 'a1b2c3d4-0000-4000-8000-000000000002', 'admin',  now()),
+  ('b1b2c3d4-0000-4000-8000-000000000002', 'a1b2c3d4-0000-4000-8000-000000000003', 'member', now());
+
+-- =============================================================================
+-- Movies
+-- =============================================================================
+INSERT INTO public.movies (group_id, tmdb_id, title, year, poster_path, genres, trailer_url, added_by, watched, watched_at, added_at) VALUES
+  -- Friday Night Movies
+  ('b1b2c3d4-0000-4000-8000-000000000001', 550,    'Fight Club',              1999, '/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg', ARRAY['Drama','Thriller'],               'https://www.youtube.com/watch?v=SUXWAEX2jlg', 'a1b2c3d4-0000-4000-8000-000000000001', false, NULL, now()),
+  ('b1b2c3d4-0000-4000-8000-000000000001', 680,    'Pulp Fiction',            1994, '/d5iIlFn5s0ImszYzBPb8JPIfbXD.jpg', ARRAY['Crime','Thriller'],                'https://www.youtube.com/watch?v=s7EdQ4FqbhY', 'a1b2c3d4-0000-4000-8000-000000000002', true,  now(), now()),
+  ('b1b2c3d4-0000-4000-8000-000000000001', 278,    'The Shawshank Redemption', 1994, '/9cjIGRHodCM9HSovJIslGwXBOaR.jpg', ARRAY['Drama','Crime'],                   NULL,                                           'a1b2c3d4-0000-4000-8000-000000000003', false, NULL, now()),
+  -- Sci-Fi Club
+  ('b1b2c3d4-0000-4000-8000-000000000002', 603,    'The Matrix',              1999, '/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', ARRAY['Action','Science Fiction'],        'https://www.youtube.com/watch?v=vKQi3bBA1y8', 'a1b2c3d4-0000-4000-8000-000000000002', false, NULL, now()),
+  ('b1b2c3d4-0000-4000-8000-000000000002', 157336, 'Interstellar',            2014, '/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg', ARRAY['Adventure','Drama','Science Fiction'], 'https://www.youtube.com/watch?v=zSWdZVtXT7E', 'a1b2c3d4-0000-4000-8000-000000000003', false, NULL, now());
+
+-- =============================================================================
+-- Landing Reel Posters (sample set of 5)
+-- =============================================================================
+INSERT INTO public.landing_reel_posters (tmdb_id, poster_path, title, refreshed_at) VALUES
+  (550,    '/pB8BM7pdSp6B6Ih7QZ4DrQ3PmJK.jpg', 'Fight Club',              now()),
+  (680,    '/d5iIlFn5s0ImszYzBPb8JPIfbXD.jpg', 'Pulp Fiction',            now()),
+  (278,    '/9cjIGRHodCM9HSovJIslGwXBOaR.jpg', 'The Shawshank Redemption', now()),
+  (603,    '/f89U3ADr1oiB1s9GkdPOEpXUk5H.jpg', 'The Matrix',              now()),
+  (157336, '/gEU2QniE6E77NI6lCU6MxlNBvIx.jpg', 'Interstellar',            now());