271 lines
9.2 KiB
PL/PgSQL
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;
|
|
$$;
|