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

271 lines
9.2 KiB
PL/PgSQL

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