-- Initial schema for AV Planner's backend, per organized-ideas.md. -- -- Two different shapes on purpose: -- * Catalog entities (manufacturers, categories, port types, cable types, -- device templates) are fully relational rows, because they must be -- independently browsable, searchable, and moderated one row at a time -- (the public/private + submission-for-review workflow). -- * Diagrams are a JSONB document per row. A diagram's devices/ports/ -- connections already carry copied-in data rather than live references -- (the "snapshot at time of use" decision), so they're inherently -- document-shaped, not relational — and RLS only ever needs to gate -- access at the whole-diagram level (owner + collaborators), never at -- the level of one device or cable inside it, so normalizing would add -- complexity without adding any real security granularity. -- ============================================================================ -- profiles — one row per auth.users row. Username is separate from email -- (organized-ideas.md §2) and unique. Account suspension uses Supabase -- Auth's own built-in ban mechanism (auth.users.banned_until, set via the -- Admin API with the service role key) rather than a column here — no need -- to reinvent it. -- ============================================================================ create table public.profiles ( id uuid primary key references auth.users (id) on delete cascade, username text not null unique, role text not null default 'regular' check (role in ('regular', 'admin', 'super_admin')), created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- Helper functions used throughout the RLS policies below. Defined here, -- right after `profiles` exists, since a `language sql` function's body is -- validated against the schema at CREATE FUNCTION time. create or replace function public.current_user_role() returns text language sql stable security definer set search_path = public as $$ select role from public.profiles where id = auth.uid(); $$; create or replace function public.is_admin() returns boolean language sql stable as $$ select coalesce(public.current_user_role() in ('admin', 'super_admin'), false); $$; create or replace function public.is_super_admin() returns boolean language sql stable as $$ select coalesce(public.current_user_role() = 'super_admin', false); $$; alter table public.profiles enable row level security; -- Anyone can read their own profile; Super Admins can read everyone's -- (Admins get no special profile visibility — their power is scoped to -- catalog submissions, not user accounts, per the role table in §2/§6). create policy "profiles_select_self_or_super_admin" on public.profiles for select using (id = auth.uid() or public.is_super_admin()); -- Users can update their own profile (e.g. username); Super Admins can -- update anyone's (e.g. role changes). Role escalation by a non-super-admin -- is blocked by the WITH CHECK clause, not just the USING clause. create policy "profiles_update_self_or_super_admin" on public.profiles for update using (id = auth.uid() or public.is_super_admin()) with check ( public.is_super_admin() or (id = auth.uid() and role = (select role from public.profiles where id = auth.uid())) ); -- Row creation happens via the trigger below, not direct client inserts. -- Deletion happens by deleting the auth.users row (via the Admin API), -- which cascades here — no direct delete policy needed. -- Auto-create a profile when a new auth user is created. Username comes -- from signup metadata (`options.data.username`) for manual signup; for -- Google SSO, the frontend collects a username up front and passes it the -- same way (per §2: "username collected up front, email pulled from Google"). create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ begin insert into public.profiles (id, username) values (new.id, new.raw_user_meta_data ->> 'username'); return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user(); -- ============================================================================ -- Catalog entities: manufacturers, device_categories, port_types, -- cable_types, device_templates (+ device_template_ports). -- -- Shared shape and RLS pattern across all of them: -- * is_public + owner_id (null owner = seeded/system row) -- * SELECT: visible if public, or you own it, or you're an Admin+ -- * INSERT: you can always create your own private (is_public = false) -- row; only Admins+ can insert directly as public -- * UPDATE: you can edit your own row while it's still private; once -- public, only Admins+ can edit it (that's what "promote in place" -- during submission review does — see catalog_submissions below) -- * DELETE: same shape as UPDATE -- ============================================================================ create table public.manufacturers ( id uuid primary key default gen_random_uuid(), name text not null, is_public boolean not null default false, owner_id uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index manufacturers_public_name_key on public.manufacturers (lower(name)) where is_public; alter table public.manufacturers enable row level security; create policy "manufacturers_select" on public.manufacturers for select using (is_public or owner_id = auth.uid() or public.is_admin()); create policy "manufacturers_insert" on public.manufacturers for insert with check ( (owner_id = auth.uid() and not is_public) or (public.is_admin() and is_public) ); create policy "manufacturers_update" on public.manufacturers for update using ((owner_id = auth.uid() and not is_public) or public.is_admin()) with check ((owner_id = auth.uid() and not is_public) or public.is_admin()); create policy "manufacturers_delete" on public.manufacturers for delete using ((owner_id = auth.uid() and not is_public) or public.is_admin()); create table public.device_categories ( id uuid primary key default gen_random_uuid(), name text not null, is_public boolean not null default false, owner_id uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); create unique index device_categories_public_name_key on public.device_categories (lower(name)) where is_public; alter table public.device_categories enable row level security; create policy "device_categories_select" on public.device_categories for select using (is_public or owner_id = auth.uid() or public.is_admin()); create policy "device_categories_insert" on public.device_categories for insert with check ( (owner_id = auth.uid() and not is_public) or (public.is_admin() and is_public) ); create policy "device_categories_update" on public.device_categories for update using ((owner_id = auth.uid() and not is_public) or public.is_admin()) with check ((owner_id = auth.uid() and not is_public) or public.is_admin()); create policy "device_categories_delete" on public.device_categories for delete using ((owner_id = auth.uid() and not is_public) or public.is_admin()); create table public.port_types ( id uuid primary key default gen_random_uuid(), name text not null, category text not null check (category in ('video', 'audio', 'network', 'usb', 'power', 'control', 'other')), family text not null, compatible_family_ids text[] not null default '{}', max_connections integer, is_public boolean not null default false, owner_id uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.port_types enable row level security; create policy "port_types_select" on public.port_types for select using (is_public or owner_id = auth.uid() or public.is_admin()); create policy "port_types_insert" on public.port_types for insert with check ( (owner_id = auth.uid() and not is_public) or (public.is_admin() and is_public) ); create policy "port_types_update" on public.port_types for update using ((owner_id = auth.uid() and not is_public) or public.is_admin()) with check ((owner_id = auth.uid() and not is_public) or public.is_admin()); create policy "port_types_delete" on public.port_types for delete using ((owner_id = auth.uid() and not is_public) or public.is_admin()); create table public.cable_types ( id uuid primary key default gen_random_uuid(), name text not null, family text not null, family2 text, unit text not null check (unit in ('ft', 'm')), cost_per_unit numeric(10, 2), is_public boolean not null default false, owner_id uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.cable_types enable row level security; create policy "cable_types_select" on public.cable_types for select using (is_public or owner_id = auth.uid() or public.is_admin()); create policy "cable_types_insert" on public.cable_types for insert with check ( (owner_id = auth.uid() and not is_public) or (public.is_admin() and is_public) ); create policy "cable_types_update" on public.cable_types for update using ((owner_id = auth.uid() and not is_public) or public.is_admin()) with check ((owner_id = auth.uid() and not is_public) or public.is_admin()); create policy "cable_types_delete" on public.cable_types for delete using ((owner_id = auth.uid() and not is_public) or public.is_admin()); create table public.device_templates ( id uuid primary key default gen_random_uuid(), name text not null, category_id uuid not null references public.device_categories (id), manufacturer_id uuid references public.manufacturers (id), model text, cost numeric(10, 2), is_public boolean not null default false, owner_id uuid references public.profiles (id) on delete set null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.device_templates enable row level security; create policy "device_templates_select" on public.device_templates for select using (is_public or owner_id = auth.uid() or public.is_admin()); create policy "device_templates_insert" on public.device_templates for insert with check ( (owner_id = auth.uid() and not is_public) or (public.is_admin() and is_public) ); create policy "device_templates_update" on public.device_templates for update using ((owner_id = auth.uid() and not is_public) or public.is_admin()) with check ((owner_id = auth.uid() and not is_public) or public.is_admin()); create policy "device_templates_delete" on public.device_templates for delete using ((owner_id = auth.uid() and not is_public) or public.is_admin()); -- A device template's ports inherit their parent template's visibility — -- there's no independent is_public/owner_id here, just a join back up. create table public.device_template_ports ( id uuid primary key default gen_random_uuid(), device_template_id uuid not null references public.device_templates (id) on delete cascade, name text not null, direction text not null check (direction in ('input', 'output', 'bidirectional')), port_type_id uuid not null references public.port_types (id), sort_order integer not null default 0 ); alter table public.device_template_ports enable row level security; create policy "device_template_ports_select" on public.device_template_ports for select using ( exists ( select 1 from public.device_templates dt where dt.id = device_template_id and (dt.is_public or dt.owner_id = auth.uid() or public.is_admin()) ) ); create policy "device_template_ports_insert" on public.device_template_ports for insert with check ( exists ( select 1 from public.device_templates dt where dt.id = device_template_id and ((dt.owner_id = auth.uid() and not dt.is_public) or public.is_admin()) ) ); create policy "device_template_ports_update" on public.device_template_ports for update using ( exists ( select 1 from public.device_templates dt where dt.id = device_template_id and ((dt.owner_id = auth.uid() and not dt.is_public) or public.is_admin()) ) ); create policy "device_template_ports_delete" on public.device_template_ports for delete using ( exists ( select 1 from public.device_templates dt where dt.id = device_template_id and ((dt.owner_id = auth.uid() and not dt.is_public) or public.is_admin()) ) ); -- ============================================================================ -- catalog_submissions — one generalized review-queue table covering all -- five catalog entity types (rather than five near-identical submission -- tables), per §3: diff-against-current review UX, notify either way, -- promote-in-place on approval, rejected stays editable for resubmission. -- `entity_id` is null for a brand-new proposed entry, set for a proposed -- edit to an existing public entry. `proposed_data` holds the submitted -- fields as JSON; the diff view is computed at the app layer by comparing -- it against the current live row (if entity_id is set). -- ============================================================================ create table public.catalog_submissions ( id uuid primary key default gen_random_uuid(), entity_type text not null check ( entity_type in ('device_template', 'port_type', 'cable_type', 'device_category', 'manufacturer') ), entity_id uuid, proposed_data jsonb not null, submitter_id uuid not null references public.profiles (id) on delete cascade, status text not null default 'pending' check (status in ('pending', 'approved', 'rejected')), reviewer_id uuid references public.profiles (id), review_reason text, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); alter table public.catalog_submissions enable row level security; create policy "catalog_submissions_select" on public.catalog_submissions for select using (submitter_id = auth.uid() or public.is_admin()); create policy "catalog_submissions_insert" on public.catalog_submissions for insert with check (submitter_id = auth.uid() and status = 'pending'); -- Submitter can revise their own pending/rejected submission (e.g. edit -- proposed_data and flip status back to 'pending' after a rejection). -- Admins can update any submission (approve/reject, set reviewer_id/ -- review_reason) — actually applying an approval to the live catalog row -- is separate application logic, not something RLS does on its own. create policy "catalog_submissions_update" on public.catalog_submissions for update using ( (submitter_id = auth.uid() and status in ('pending', 'rejected')) or public.is_admin() ) with check ( (submitter_id = auth.uid() and status in ('pending', 'rejected')) or public.is_admin() ); -- Submitter can withdraw their own still-pending submission. create policy "catalog_submissions_delete" on public.catalog_submissions for delete using (submitter_id = auth.uid() and status = 'pending'); -- ============================================================================ -- diagrams — JSONB document per diagram (see file header for why). -- ============================================================================ create table public.diagrams ( id uuid primary key default gen_random_uuid(), name text not null, owner_id uuid not null references public.profiles (id) on delete cascade, data jsonb not null, created_at timestamptz not null default now(), updated_at timestamptz not null default now() ); -- Created here (before diagrams' own RLS policies) because those policies -- need to reference it — table existence is checked at CREATE POLICY time, -- same as it was for the helper functions above. Its own RLS/policies are -- defined further down, once `diagrams` policies no longer need editing. create table public.diagram_collaborators ( diagram_id uuid not null references public.diagrams (id) on delete cascade, user_id uuid not null references public.profiles (id) on delete cascade, permission text not null check (permission in ('view', 'edit')), created_at timestamptz not null default now(), primary key (diagram_id, user_id) ); -- `diagrams` and `diagram_collaborators` policies each need to check the -- other table (is this user a collaborator? / does this user own the -- diagram?). A direct correlated subquery from one RLS-protected table into -- another RLS-protected table that itself queries back is a well-known -- Postgres RLS trap: each table's policy re-triggers the other's, forever -- ("infinite recursion detected in policy for relation..."). The fix is to -- route the cross-table check through a SECURITY DEFINER function — it runs -- as the function's owner (postgres, which bypasses RLS as the table -- owner), so the lookup inside it never re-enters either policy. create or replace function public.diagram_owner_id(p_diagram_id uuid) returns uuid language sql stable security definer set search_path = public as $$ select owner_id from public.diagrams where id = p_diagram_id; $$; create or replace function public.diagram_collaborator_permission(p_diagram_id uuid, p_user_id uuid) returns text language sql stable security definer set search_path = public as $$ select permission from public.diagram_collaborators where diagram_id = p_diagram_id and user_id = p_user_id; $$; alter table public.diagrams enable row level security; create policy "diagrams_select" on public.diagrams for select using ( owner_id = auth.uid() or public.is_super_admin() or public.diagram_collaborator_permission(id, auth.uid()) is not null ); create policy "diagrams_insert" on public.diagrams for insert with check (owner_id = auth.uid()); create policy "diagrams_update" on public.diagrams for update using ( owner_id = auth.uid() or public.is_super_admin() or public.diagram_collaborator_permission(id, auth.uid()) = 'edit' ); create policy "diagrams_delete" on public.diagrams for delete using (owner_id = auth.uid() or public.is_super_admin()); -- ============================================================================ -- diagram_collaborators — per-collaborator view/edit permission (§8). -- Only the diagram's owner (or a Super Admin) manages the collaborator -- list; a collaborator can see their own membership row but can't add -- others, matching "the owner picks who has access" from the plan. -- ============================================================================ alter table public.diagram_collaborators enable row level security; create policy "diagram_collaborators_select" on public.diagram_collaborators for select using ( user_id = auth.uid() or public.is_super_admin() or public.diagram_owner_id(diagram_id) = auth.uid() ); create policy "diagram_collaborators_insert" on public.diagram_collaborators for insert with check ( public.is_super_admin() or public.diagram_owner_id(diagram_id) = auth.uid() ); create policy "diagram_collaborators_update" on public.diagram_collaborators for update using ( public.is_super_admin() or public.diagram_owner_id(diagram_id) = auth.uid() ); create policy "diagram_collaborators_delete" on public.diagram_collaborators for delete using ( public.is_super_admin() or public.diagram_owner_id(diagram_id) = auth.uid() ); -- ============================================================================ -- diagram_snapshots — rolling undo/recovery history (§8). Immutable once -- written (no update policy); retention/pruning to "recent" snapshots is a -- scheduled job running as service_role (bypasses RLS entirely), not -- modeled here — this table just needs to accept writes and be readable by -- whoever can already see/edit the diagram. -- ============================================================================ create table public.diagram_snapshots ( id uuid primary key default gen_random_uuid(), diagram_id uuid not null references public.diagrams (id) on delete cascade, data jsonb not null, saved_by uuid references public.profiles (id), created_at timestamptz not null default now() ); alter table public.diagram_snapshots enable row level security; -- Same recursion trap as diagrams/diagram_collaborators applies here too -- (this table's policy would otherwise correlated-subquery into both of -- those RLS-protected tables) — routed through the same SECURITY DEFINER -- helper functions for the same reason. create policy "diagram_snapshots_select" on public.diagram_snapshots for select using ( public.is_super_admin() or public.diagram_owner_id(diagram_id) = auth.uid() or public.diagram_collaborator_permission(diagram_id, auth.uid()) is not null ); create policy "diagram_snapshots_insert" on public.diagram_snapshots for insert with check ( public.is_super_admin() or public.diagram_owner_id(diagram_id) = auth.uid() or public.diagram_collaborator_permission(diagram_id, auth.uid()) = 'edit' );