-- ============================================================================ -- i18n readiness: per-user locale column + error codes in triggers/RPCs. -- Clients map codes to localized strings via packages/shared/src/i18n/error-map. -- Keep codes stable — they are the public API between DB and clients. -- ============================================================================ -- ---------------------------------------------------------------------------- -- profiles.locale -- ---------------------------------------------------------------------------- alter table public.profiles add column if not exists locale text not null default 'en'; alter table public.profiles drop constraint if exists profiles_locale_supported; alter table public.profiles add constraint profiles_locale_supported check (locale in ('en', 'de')); -- ---------------------------------------------------------------------------- -- Rewrite handle_new_user to: -- * raise error codes instead of prose -- * accept optional `locale` in user_metadata -- ---------------------------------------------------------------------------- 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_locale 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'), ''); v_locale := lower(trim(new.raw_user_meta_data->>'locale')); if v_display_name is null then v_display_name := v_username; end if; if v_locale is null or v_locale not in ('en', 'de') then v_locale := 'en'; end if; if v_invite_code is null or length(v_invite_code) = 0 then raise exception 'ERR_INVITE_CODE_REQUIRED'; end if; if v_username is null or v_username !~ '^[a-z0-9_]{3,32}$' then raise exception 'ERR_USERNAME_INVALID'; end if; if not coalesce((select (value)::boolean from public.admin_settings where key = 'invites_enabled'), true) then raise exception 'ERR_INVITES_DISABLED'; end if; select * into v_invite from public.invites where code = v_invite_code for update; if not found then raise exception 'ERR_INVITE_NOT_FOUND'; end if; if v_invite.disabled then raise exception 'ERR_INVITE_DISABLED'; end if; if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'ERR_INVITE_EXPIRED'; end if; if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'ERR_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, locale) values (new.id, v_username, v_display_name, v_locale); return new; end; $$; -- ---------------------------------------------------------------------------- -- messages_update_guard with error codes. -- ---------------------------------------------------------------------------- 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 if old.deleted_at is not null then raise exception 'ERR_MESSAGE_DELETED'; end if; 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 'ERR_DELETE_FORBIDDEN'; end if; return new; end if; if old.sender_id <> caller then raise exception 'ERR_EDIT_NOT_SENDER'; end if; if now() - old.created_at > interval '24 hours' then raise exception 'ERR_EDIT_WINDOW_EXPIRED'; end if; new.edited_at := now(); new.conversation_id := old.conversation_id; new.sender_id := old.sender_id; new.created_at := old.created_at; return new; end; $$; -- ---------------------------------------------------------------------------- -- envelopes_update_guard with error codes. -- ---------------------------------------------------------------------------- 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 'ERR_ENVELOPE_NOT_SENDER'; end if; if now() - msg_created > interval '24 hours' then raise exception 'ERR_ENVELOPE_WINDOW_EXPIRED'; end if; return new; end; $$; -- ---------------------------------------------------------------------------- -- friendships_update_guard with error codes. -- ---------------------------------------------------------------------------- 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 if old.status = 'pending' and new.status = 'accepted' then if caller = old.requested_by then raise exception 'ERR_FRIEND_SELF_ACCEPT'; end if; new.accepted_at := now(); return new; end if; if new.status = 'blocked' then return new; end if; if old.status = 'blocked' and new.status <> 'blocked' then return new; end if; raise exception 'ERR_FRIEND_BAD_TRANSITION'; end; $$; -- ---------------------------------------------------------------------------- -- RPCs with error codes. -- ---------------------------------------------------------------------------- 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 'ERR_NOT_AUTH'; end if; if caller = target_user_id then raise exception 'ERR_DM_SELF'; end if; 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 'ERR_DM_STRANGERS_DISABLED'; 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; $$; 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 'ERR_NO_PENDING_DM'; end if; end; $$; 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 'ERR_GROUP_INVITE_NOT_FOUND'; end if; if v_invite.disabled then raise exception 'ERR_GROUP_INVITE_DISABLED'; end if; if v_invite.expires_at is not null and v_invite.expires_at < now() then raise exception 'ERR_GROUP_INVITE_EXPIRED'; end if; if v_invite.uses_limit is not null and v_invite.uses_count >= v_invite.uses_limit then raise exception 'ERR_GROUP_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; $$; 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 'ERR_NOT_AUTH'; end if; if auth.uid() = target_user_id then raise exception 'ERR_FRIEND_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; $$;