Files
ChatApp/supabase/migrations/20260417000001_initial_schema.sql
T
2026-04-18 23:11:35 +02:00

959 lines
39 KiB
PL/PgSQL

-- ============================================================================
-- 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;