-- ============================================================================ -- Milestone 1 initial schema. -- -- Design decisions (locked in by owner): -- * Per-device X25519 keypairs. Messages encrypted per recipient device. -- * Admin flag lives on profiles. -- * DMs require friendship OR allow_dms_from_strangers on receiver. -- * Group roles: admin / mod / member. -- * Edits: own messages only, 24h window. -- * Soft-delete everywhere. -- * Reactions: multiple emojis per user-message, same emoji once. -- * Read receipts: reciprocal opt-out via profiles.show_read_receipts. -- * Presence state only (no last-seen timestamp). -- * Attachments: separate table, images for M1. -- * Server is zero-knowledge: only envelopes hold ciphertext. -- ============================================================================ -- ============================================================================ -- Extensions -- ============================================================================ create extension if not exists citext; create extension if not exists pgcrypto; -- ============================================================================ -- Enums -- ============================================================================ create type public.presence_state as enum ('online', 'idle', 'dnd', 'invisible', 'offline'); create type public.conversation_type as enum ('dm', 'group'); create type public.member_role as enum ('admin', 'mod', 'member'); create type public.friendship_status as enum ('pending', 'accepted', 'blocked'); create type public.device_platform as enum ('ios', 'android', 'macos', 'windows', 'linux'); -- ============================================================================ -- profiles -- One row per auth.users row. username is the login handle (unique, case- -- insensitive). display_name is free-form. -- ============================================================================ create table public.profiles ( user_id uuid primary key references auth.users(id) on delete cascade, username citext not null unique, display_name text not null, avatar_url text, status_message text, presence_state public.presence_state not null default 'offline', show_read_receipts boolean not null default true, allow_dms_from_strangers boolean not null default true, is_admin boolean not null default false, banned boolean not null default false, blocked_from_inviting boolean not null default false, created_at timestamptz not null default now(), updated_at timestamptz not null default now(), constraint profiles_username_format check (username ~ '^[a-z0-9_]{3,32}$'), constraint profiles_display_name_len check (length(display_name) between 1 and 64), constraint profiles_status_message_len check (status_message is null or length(status_message) <= 128) ); -- ============================================================================ -- devices -- Each physical device registers itself and owns its own X25519 public key. -- Private key never leaves the device. Messages encrypted per recipient -- device => per-device envelope rows. -- ============================================================================ create table public.devices ( id uuid primary key default gen_random_uuid(), user_id uuid not null references auth.users(id) on delete cascade, name text not null, platform public.device_platform not null, public_key bytea not null, last_seen_at timestamptz not null default now(), created_at timestamptz not null default now(), constraint devices_name_len check (length(name) between 1 and 64) ); create index devices_user_idx on public.devices(user_id); -- ============================================================================ -- push_tokens -- ============================================================================ create table public.push_tokens ( device_id uuid primary key references public.devices(id) on delete cascade, platform public.device_platform not null, token text not null, updated_at timestamptz not null default now() ); -- ============================================================================ -- invites (signup invites, admin-controlled) -- ============================================================================ create table public.invites ( code text primary key, created_by uuid references auth.users(id) on delete set null, uses_limit integer, -- null = unlimited uses_count integer not null default 0, expires_at timestamptz, -- null = never disabled boolean not null default false, created_at timestamptz not null default now(), constraint invites_code_len check (length(code) between 4 and 64), constraint invites_uses_nonneg check (uses_count >= 0 and (uses_limit is null or uses_limit > 0)) ); create index invites_created_by_idx on public.invites(created_by); -- ============================================================================ -- admin_settings (global feature flags) -- ============================================================================ create table public.admin_settings ( key text primary key, value jsonb not null, updated_at timestamptz not null default now() ); -- Seed default admin settings. insert into public.admin_settings (key, value) values ('invites_enabled', 'true'::jsonb), ('default_invite_ttl_days', '30'::jsonb), ('default_invite_uses', '1'::jsonb) on conflict (key) do nothing; -- ============================================================================ -- friendships -- Stored symmetrically: user_lo < user_hi to keep (A,B) == (B,A) one row. -- requested_by records who initiated the request. -- ============================================================================ create table public.friendships ( user_lo uuid not null references auth.users(id) on delete cascade, user_hi uuid not null references auth.users(id) on delete cascade, status public.friendship_status not null default 'pending', requested_by uuid not null references auth.users(id) on delete cascade, created_at timestamptz not null default now(), accepted_at timestamptz, primary key (user_lo, user_hi), constraint friendships_ordered check (user_lo < user_hi) ); create index friendships_hi_idx on public.friendships(user_hi); -- ============================================================================ -- conversations -- ============================================================================ create table public.conversations ( id uuid primary key default gen_random_uuid(), type public.conversation_type not null, name text, -- null for DMs avatar_url text, -- null for DMs created_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now(), constraint conversations_group_has_name check ( type = 'dm' or (type = 'group' and name is not null and length(name) between 1 and 64) ) ); -- ============================================================================ -- conversation_members -- accepted=false means the DM is still in the "request" inbox for that user. -- ============================================================================ create table public.conversation_members ( conversation_id uuid not null references public.conversations(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, role public.member_role not null default 'member', accepted boolean not null default true, joined_at timestamptz not null default now(), primary key (conversation_id, user_id) ); create index conversation_members_user_idx on public.conversation_members(user_id); -- ============================================================================ -- group_invites (invite links to join groups) -- ============================================================================ create table public.group_invites ( code text primary key, conversation_id uuid not null references public.conversations(id) on delete cascade, created_by uuid references auth.users(id) on delete set null, uses_limit integer, uses_count integer not null default 0, expires_at timestamptz, disabled boolean not null default false, created_at timestamptz not null default now(), constraint group_invites_code_len check (length(code) between 4 and 64), constraint group_invites_uses_nonneg check (uses_count >= 0 and (uses_limit is null or uses_limit > 0)) ); create index group_invites_conversation_idx on public.group_invites(conversation_id); -- ============================================================================ -- messages (plaintext metadata only — ciphertext lives in envelopes) -- ============================================================================ create table public.messages ( id uuid primary key default gen_random_uuid(), conversation_id uuid not null references public.conversations(id) on delete cascade, sender_id uuid not null references auth.users(id) on delete set null, reply_to_id uuid references public.messages(id) on delete set null, edited_at timestamptz, deleted_at timestamptz, deleted_by uuid references auth.users(id) on delete set null, created_at timestamptz not null default now() ); create index messages_conversation_created_idx on public.messages(conversation_id, created_at desc); -- ============================================================================ -- message_envelopes (per-recipient-device ciphertext) -- ============================================================================ create table public.message_envelopes ( message_id uuid not null references public.messages(id) on delete cascade, recipient_device_id uuid not null references public.devices(id) on delete cascade, ciphertext bytea not null, nonce bytea not null, primary key (message_id, recipient_device_id) ); create index message_envelopes_device_idx on public.message_envelopes(recipient_device_id); -- ============================================================================ -- message_reactions -- ============================================================================ create table public.message_reactions ( message_id uuid not null references public.messages(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, emoji text not null, created_at timestamptz not null default now(), primary key (message_id, user_id, emoji), constraint message_reactions_emoji_len check (length(emoji) between 1 and 16) ); -- ============================================================================ -- message_reads -- ============================================================================ create table public.message_reads ( message_id uuid not null references public.messages(id) on delete cascade, user_id uuid not null references auth.users(id) on delete cascade, read_at timestamptz not null default now(), primary key (message_id, user_id) ); -- ============================================================================ -- message_attachments (encrypted blobs in Supabase Storage) -- ============================================================================ create table public.message_attachments ( id uuid primary key default gen_random_uuid(), message_id uuid not null references public.messages(id) on delete cascade, storage_path text not null, nonce bytea not null, mime_type text not null, size_bytes bigint not null, width integer, height integer, created_at timestamptz not null default now(), constraint message_attachments_size_pos check (size_bytes > 0) ); create index message_attachments_message_idx on public.message_attachments(message_id); -- ============================================================================ -- Helper functions (SECURITY DEFINER to dodge RLS recursion) -- ============================================================================ create or replace function public.is_conversation_member(cid uuid) returns boolean language sql security definer stable set search_path = public as $$ select exists ( select 1 from public.conversation_members where conversation_id = cid and user_id = auth.uid() and accepted ); $$; create or replace function public.is_conversation_mod_or_higher(cid uuid) returns boolean language sql security definer stable set search_path = public as $$ select exists ( select 1 from public.conversation_members where conversation_id = cid and user_id = auth.uid() and accepted and role in ('admin', 'mod') ); $$; create or replace function public.is_conversation_admin(cid uuid) returns boolean language sql security definer stable set search_path = public as $$ select exists ( select 1 from public.conversation_members where conversation_id = cid and user_id = auth.uid() and accepted and role = 'admin' ); $$; create or replace function public.are_friends(a uuid, b uuid) returns boolean language sql security definer stable set search_path = public as $$ select exists ( select 1 from public.friendships where status = 'accepted' and ((user_lo = least(a,b) and user_hi = greatest(a,b))) ); $$; create or replace function public.current_user_is_admin() returns boolean language sql security definer stable set search_path = public as $$ select coalesce((select is_admin from public.profiles where user_id = auth.uid()), false); $$; -- ============================================================================ -- Enable RLS on everything user-facing -- ============================================================================ alter table public.profiles enable row level security; alter table public.devices enable row level security; alter table public.push_tokens enable row level security; alter table public.invites enable row level security; alter table public.admin_settings enable row level security; alter table public.friendships enable row level security; alter table public.conversations enable row level security; alter table public.conversation_members enable row level security; alter table public.group_invites enable row level security; alter table public.messages enable row level security; alter table public.message_envelopes enable row level security; alter table public.message_reactions enable row level security; alter table public.message_reads enable row level security; alter table public.message_attachments enable row level security; -- ============================================================================ -- profiles policies -- Everyone authenticated can look up any profile (for peer lookup + search). -- Only the owner can update their row. No direct INSERT (handled by trigger). -- ============================================================================ create policy profiles_select_all on public.profiles for select to authenticated using (true); create policy profiles_update_own on public.profiles for update to authenticated using (auth.uid() = user_id) with check (auth.uid() = user_id); -- ============================================================================ -- devices policies -- ============================================================================ create policy devices_select_own on public.devices for select to authenticated using (user_id = auth.uid()); -- Any authenticated user can read public keys of peers they converse with. -- Simpler v1: expose all public keys. Tighten later. create policy devices_select_all_public_key on public.devices for select to authenticated using (true); create policy devices_insert_own on public.devices for insert to authenticated with check (user_id = auth.uid()); create policy devices_update_own on public.devices for update to authenticated using (user_id = auth.uid()) with check (user_id = auth.uid()); create policy devices_delete_own on public.devices for delete to authenticated using (user_id = auth.uid()); -- ============================================================================ -- push_tokens policies -- ============================================================================ create policy push_tokens_select_own on public.push_tokens for select to authenticated using (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid())); create policy push_tokens_write_own on public.push_tokens for all to authenticated using (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid())) with check (exists (select 1 from public.devices d where d.id = device_id and d.user_id = auth.uid())); -- ============================================================================ -- invites policies (admin-managed) -- ============================================================================ create policy invites_select_own_or_admin on public.invites for select to authenticated using (created_by = auth.uid() or public.current_user_is_admin()); create policy invites_insert_own on public.invites for insert to authenticated with check ( created_by = auth.uid() and not coalesce((select blocked_from_inviting from public.profiles where user_id = auth.uid()), false) and coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) ); create policy invites_update_admin on public.invites for update to authenticated using (public.current_user_is_admin()) with check (public.current_user_is_admin()); create policy invites_delete_admin on public.invites for delete to authenticated using (public.current_user_is_admin()); -- ============================================================================ -- admin_settings policies -- ============================================================================ create policy admin_settings_select_all on public.admin_settings for select to authenticated using (true); create policy admin_settings_write_admin on public.admin_settings for all to authenticated using (public.current_user_is_admin()) with check (public.current_user_is_admin()); -- ============================================================================ -- friendships policies -- ============================================================================ create policy friendships_select_own on public.friendships for select to authenticated using (user_lo = auth.uid() or user_hi = auth.uid()); -- Insert only allowed when: -- - row is ordered (user_lo < user_hi) -- - one side is the caller -- - requested_by = caller -- - status starts at 'pending' create policy friendships_insert_own on public.friendships for insert to authenticated with check ( user_lo < user_hi and (user_lo = auth.uid() or user_hi = auth.uid()) and requested_by = auth.uid() and status = 'pending' ); -- Update allowed for the recipient (the party that did NOT request) to accept or -- block. Caller can block from their side regardless. create policy friendships_update_involved on public.friendships for update to authenticated using (user_lo = auth.uid() or user_hi = auth.uid()) with check (user_lo = auth.uid() or user_hi = auth.uid()); create policy friendships_delete_own on public.friendships for delete to authenticated using (user_lo = auth.uid() or user_hi = auth.uid()); -- ============================================================================ -- conversations policies -- ============================================================================ create policy conversations_select_member on public.conversations for select to authenticated using (public.is_conversation_member(id)); create policy conversations_insert_authenticated on public.conversations for insert to authenticated with check (created_by = auth.uid()); -- Only admins of a group can update metadata (name, avatar). create policy conversations_update_admin on public.conversations for update to authenticated using (type = 'group' and public.is_conversation_admin(id)) with check (type = 'group' and public.is_conversation_admin(id)); -- ============================================================================ -- conversation_members policies -- ============================================================================ create policy members_select_co on public.conversation_members for select to authenticated using (public.is_conversation_member(conversation_id)); -- Insert cases: -- (a) Self-inserting as creator (user_id = auth.uid()) -- (b) Adding a peer to a DM you own -- - dm conversation -- - target is a friend OR target allows DMs from strangers -- (c) Admin/Mod adding a friend to a group create policy members_insert_complex on public.conversation_members for insert to authenticated with check ( -- (a) Self-insert (creator bootstrap or accepting group invite via RPC) user_id = auth.uid() -- (b) DM peer add by creator or ( exists (select 1 from public.conversations c where c.id = conversation_id and c.type = 'dm' and c.created_by = auth.uid()) and ( public.are_friends(auth.uid(), user_id) or coalesce((select allow_dms_from_strangers from public.profiles where profiles.user_id = conversation_members.user_id), false) ) ) -- (c) Admin/Mod adding friend to group or ( public.is_conversation_mod_or_higher(conversation_id) and public.are_friends(auth.uid(), user_id) ) ); -- Users can accept their own DM requests (flip accepted to true) and -- admins/mods can change roles. Rest is restricted via triggers. create policy members_update_self_or_admin on public.conversation_members for update to authenticated using (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id)) with check (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id)); -- Users can remove themselves. Admins/mods can kick. create policy members_delete_self_or_admin on public.conversation_members for delete to authenticated using (user_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id)); -- ============================================================================ -- group_invites policies -- ============================================================================ create policy group_invites_select_member on public.group_invites for select to authenticated using (public.is_conversation_member(conversation_id)); create policy group_invites_insert_mod on public.group_invites for insert to authenticated with check (public.is_conversation_mod_or_higher(conversation_id) and created_by = auth.uid()); create policy group_invites_update_admin on public.group_invites for update to authenticated using (public.is_conversation_admin(conversation_id)) with check (public.is_conversation_admin(conversation_id)); create policy group_invites_delete_admin on public.group_invites for delete to authenticated using (public.is_conversation_admin(conversation_id)); -- ============================================================================ -- messages policies -- ============================================================================ create policy messages_select_member on public.messages for select to authenticated using (public.is_conversation_member(conversation_id)); create policy messages_insert_member on public.messages for insert to authenticated with check ( sender_id = auth.uid() and public.is_conversation_member(conversation_id) ); -- UPDATE allowed by: -- - sender editing within 24h (edited_at set) -- - sender soft-deleting own message (deleted_at set, deleted_by = self) -- - mod/admin soft-deleting any message in conversation (deleted_at set) -- Detailed field-level enforcement lives in the messages_update_guard trigger. create policy messages_update_guarded on public.messages for update to authenticated using ( sender_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id) ) with check ( sender_id = auth.uid() or public.is_conversation_mod_or_higher(conversation_id) ); -- ============================================================================ -- message_envelopes policies -- ============================================================================ -- Sender inserts envelopes for all recipient devices (including their own). create policy envelopes_insert_sender on public.message_envelopes for insert to authenticated with check ( exists ( select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid() ) ); -- Reader can fetch envelopes targeted at one of their own devices. create policy envelopes_select_own_device on public.message_envelopes for select to authenticated using ( exists ( select 1 from public.devices d where d.id = recipient_device_id and d.user_id = auth.uid() ) ); -- Sender updates envelopes when editing message (same 24h window enforced via trigger). create policy envelopes_update_sender on public.message_envelopes for update to authenticated using ( exists (select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid()) ) with check ( exists (select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid()) ); -- Cascade-delete on message_id handles most cleanup. -- ============================================================================ -- message_reactions policies -- ============================================================================ create policy reactions_select_member on public.message_reactions for select to authenticated using (exists ( select 1 from public.messages m where m.id = message_id and public.is_conversation_member(m.conversation_id) )); create policy reactions_insert_own on public.message_reactions for insert to authenticated with check ( user_id = auth.uid() and exists ( select 1 from public.messages m where m.id = message_id and public.is_conversation_member(m.conversation_id) ) ); create policy reactions_delete_own on public.message_reactions for delete to authenticated using (user_id = auth.uid()); -- ============================================================================ -- message_reads policies -- Reciprocal opt-out: a user without show_read_receipts cannot see others' reads -- and others cannot see theirs. -- ============================================================================ create policy reads_select_reciprocal on public.message_reads for select to authenticated using ( -- Always see your own reads. user_id = auth.uid() or ( -- See others' reads only if caller has receipts on AND target has receipts on. coalesce((select show_read_receipts from public.profiles where profiles.user_id = auth.uid()), true) and coalesce((select show_read_receipts from public.profiles where profiles.user_id = message_reads.user_id), true) and exists ( select 1 from public.messages m where m.id = message_id and public.is_conversation_member(m.conversation_id) ) ) ); create policy reads_insert_own on public.message_reads for insert to authenticated with check ( user_id = auth.uid() and exists ( select 1 from public.messages m where m.id = message_id and public.is_conversation_member(m.conversation_id) ) ); -- ============================================================================ -- message_attachments policies -- ============================================================================ create policy attachments_select_member on public.message_attachments for select to authenticated using (exists ( select 1 from public.messages m where m.id = message_id and public.is_conversation_member(m.conversation_id) )); create policy attachments_insert_sender on public.message_attachments for insert to authenticated with check (exists ( select 1 from public.messages m where m.id = message_id and m.sender_id = auth.uid() )); -- ============================================================================ -- Triggers -- ============================================================================ -- profiles.updated_at bump create or replace function public.set_updated_at() returns trigger language plpgsql as $$ begin new.updated_at = now(); return new; end; $$; create trigger profiles_updated_at before update on public.profiles for each row execute function public.set_updated_at(); -- messages guard: enforce edit-window, deletion rules, envelope-edit-window. create or replace function public.messages_update_guard() returns trigger language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); is_mod boolean := public.is_conversation_mod_or_higher(new.conversation_id); begin -- Already deleted? Freeze. if old.deleted_at is not null then raise exception 'message already deleted'; end if; -- If deleting (deleted_at transitioning from null to not-null) if old.deleted_at is null and new.deleted_at is not null then if new.deleted_by is null then new.deleted_by := caller; end if; if not (old.sender_id = caller or is_mod) then raise exception 'not allowed to delete this message'; end if; return new; end if; -- Otherwise: edit. Only sender, only within 24h. if old.sender_id <> caller then raise exception 'only sender can edit'; end if; if now() - old.created_at > interval '24 hours' then raise exception 'edit window (24h) expired'; end if; new.edited_at := now(); -- Preserve immutable fields. new.conversation_id := old.conversation_id; new.sender_id := old.sender_id; new.created_at := old.created_at; return new; end; $$; create trigger messages_update_guard_trigger before update on public.messages for each row execute function public.messages_update_guard(); -- envelope edit guard (same 24h window) create or replace function public.envelopes_update_guard() returns trigger language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); msg_created timestamptz; msg_sender uuid; begin select created_at, sender_id into msg_created, msg_sender from public.messages where id = new.message_id; if msg_sender <> caller then raise exception 'only sender can rewrite envelopes'; end if; if now() - msg_created > interval '24 hours' then raise exception 'envelope edit window (24h) expired'; end if; return new; end; $$; create trigger envelopes_update_guard_trigger before update on public.message_envelopes for each row execute function public.envelopes_update_guard(); -- Friendship update guard: only the non-requester can accept; either party can block. create or replace function public.friendships_update_guard() returns trigger language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); begin -- status transition: pending -> accepted if old.status = 'pending' and new.status = 'accepted' then if caller = old.requested_by then raise exception 'requester cannot self-accept'; end if; new.accepted_at := now(); return new; end if; -- any -> blocked by either party: allow if new.status = 'blocked' then return new; end if; -- blocked -> anything: only blocker can unblock (requested_by holds blocker id -- after block). To keep simple: allow any involved party to unblock. if old.status = 'blocked' and new.status <> 'blocked' then return new; end if; -- everything else: freeze raise exception 'illegal friendship transition: % -> %', old.status, new.status; end; $$; create trigger friendships_update_guard_trigger before update on public.friendships for each row execute function public.friendships_update_guard(); -- Signup trigger: consume invite, create profile row. -- Expects raw_user_meta_data to contain: invite_code, username, display_name. -- public_key is registered separately via devices table after login. create or replace function public.handle_new_user() returns trigger language plpgsql security definer set search_path = public as $$ declare v_invite_code text; v_username text; v_display_name text; v_invite public.invites%rowtype; begin v_invite_code := new.raw_user_meta_data->>'invite_code'; v_username := lower(trim(new.raw_user_meta_data->>'username')); v_display_name := nullif(trim(new.raw_user_meta_data->>'display_name'), ''); if v_display_name is null then v_display_name := v_username; end if; if v_invite_code is null or length(v_invite_code) = 0 then raise exception 'invite_code required'; end if; if v_username is null or v_username !~ '^[a-z0-9_]{3,32}$' then raise exception 'username invalid (lowercase alphanumeric + underscore, 3-32 chars)'; end if; if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then raise exception 'invites globally disabled'; end if; select * into v_invite from public.invites where code = v_invite_code for update; if not found then raise exception 'invalid invite'; end if; if v_invite.disabled then raise exception 'invite disabled'; end if; if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'invite expired'; end if; if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'invite exhausted'; end if; update public.invites set uses_count = uses_count + 1 where code = v_invite.code; insert into public.profiles (user_id, username, display_name) values (new.id, v_username, v_display_name); return new; end; $$; create trigger on_auth_user_created after insert on auth.users for each row execute function public.handle_new_user(); -- ============================================================================ -- RPCs (SECURITY DEFINER) for flows too complex for RLS alone -- ============================================================================ -- Create a DM with a target user. Returns conversation_id. create or replace function public.create_dm(target_user_id uuid) returns uuid language plpgsql security definer set search_path = public as $$ declare caller uuid := auth.uid(); existing_conv uuid; new_conv uuid; target_accepts boolean; friends boolean; begin if caller is null then raise exception 'not authenticated'; end if; if caller = target_user_id then raise exception 'cannot DM self'; end if; -- Existing DM between these two? select c.id into existing_conv from public.conversations c join public.conversation_members m1 on m1.conversation_id = c.id and m1.user_id = caller join public.conversation_members m2 on m2.conversation_id = c.id and m2.user_id = target_user_id where c.type = 'dm' limit 1; if existing_conv is not null then return existing_conv; end if; friends := public.are_friends(caller, target_user_id); select allow_dms_from_strangers into target_accepts from public.profiles where user_id = target_user_id; if not friends and not coalesce(target_accepts, false) then raise exception 'target does not accept DMs from strangers'; end if; insert into public.conversations (type, created_by) values ('dm', caller) returning id into new_conv; insert into public.conversation_members (conversation_id, user_id, role, accepted) values (new_conv, caller, 'member', true), (new_conv, target_user_id, 'member', friends); return new_conv; end; $$; revoke all on function public.create_dm(uuid) from public; grant execute on function public.create_dm(uuid) to authenticated; -- Accept a pending DM request (flip own member row to accepted=true). create or replace function public.accept_dm(conversation_id uuid) returns void language plpgsql security definer set search_path = public as $$ begin update public.conversation_members set accepted = true where conversation_members.conversation_id = accept_dm.conversation_id and user_id = auth.uid(); if not found then raise exception 'no pending DM found'; end if; end; $$; revoke all on function public.accept_dm(uuid) from public; grant execute on function public.accept_dm(uuid) to authenticated; -- Redeem a group invite code. create or replace function public.redeem_group_invite(code text) returns uuid language plpgsql security definer set search_path = public as $$ declare v_invite public.group_invites%rowtype; begin select * into v_invite from public.group_invites where group_invites.code = redeem_group_invite.code for update; if not found then raise exception 'invalid invite'; end if; if v_invite.disabled then raise exception 'invite disabled'; end if; if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'invite expired'; end if; if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'invite exhausted'; end if; insert into public.conversation_members (conversation_id, user_id, role, accepted) values (v_invite.conversation_id, auth.uid(), 'member', true) on conflict do nothing; update public.group_invites set uses_count = uses_count + 1 where group_invites.code = v_invite.code; return v_invite.conversation_id; end; $$; revoke all on function public.redeem_group_invite(text) from public; grant execute on function public.redeem_group_invite(text) to authenticated; -- Send a friend request (normalises ordering). create or replace function public.send_friend_request(target_user_id uuid) returns void language plpgsql security definer set search_path = public as $$ declare lo uuid := least(auth.uid(), target_user_id); hi uuid := greatest(auth.uid(), target_user_id); begin if auth.uid() is null then raise exception 'not authenticated'; end if; if auth.uid() = target_user_id then raise exception 'cannot befriend self'; end if; insert into public.friendships (user_lo, user_hi, requested_by, status) values (lo, hi, auth.uid(), 'pending') on conflict (user_lo, user_hi) do nothing; end; $$; revoke all on function public.send_friend_request(uuid) from public; grant execute on function public.send_friend_request(uuid) to authenticated; -- ============================================================================ -- Realtime publications -- ============================================================================ alter publication supabase_realtime add table public.messages; alter publication supabase_realtime add table public.message_envelopes; alter publication supabase_realtime add table public.message_reactions; alter publication supabase_realtime add table public.message_reads; alter publication supabase_realtime add table public.conversation_members; alter publication supabase_realtime add table public.conversations; alter publication supabase_realtime add table public.profiles; alter publication supabase_realtime add table public.friendships;