611 lines
20 KiB
PL/PgSQL
611 lines
20 KiB
PL/PgSQL
BEGIN;
|
|
|
|
-- Direct multi-row team mutations cannot preserve ownership invariants. All
|
|
-- writes below are routed through locked SECURITY DEFINER RPCs.
|
|
DROP POLICY IF EXISTS "teams_insert_own" ON public.teams;
|
|
DROP POLICY IF EXISTS "team_members_insert_admin" ON public.team_members;
|
|
DROP POLICY IF EXISTS "team_members_update_admin" ON public.team_members;
|
|
DROP POLICY IF EXISTS "team_members_delete_admin_or_self" ON public.team_members;
|
|
DROP POLICY IF EXISTS "team_invites_insert_admin" ON public.team_invites;
|
|
DROP POLICY IF EXISTS "team_invites_read" ON public.team_invites;
|
|
DROP POLICY IF EXISTS "team_invites_delete" ON public.team_invites;
|
|
|
|
CREATE TEMP TABLE duplicate_active_team_invites ON COMMIT DROP AS
|
|
SELECT
|
|
id,
|
|
row_number() OVER (
|
|
PARTITION BY team_id, lower(btrim(email))
|
|
ORDER BY created_at DESC, id
|
|
) AS duplicate_rank
|
|
FROM public.team_invites
|
|
WHERE accepted_at IS NULL;
|
|
|
|
DELETE FROM public.team_invites AS invite
|
|
USING duplicate_active_team_invites AS duplicate
|
|
WHERE invite.id = duplicate.id AND duplicate.duplicate_rank > 1;
|
|
|
|
CREATE UNIQUE INDEX IF NOT EXISTS idx_team_invites_active_email
|
|
ON public.team_invites(team_id, lower(btrim(email)))
|
|
WHERE accepted_at IS NULL;
|
|
|
|
CREATE OR REPLACE FUNCTION public.create_team(team_name text)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
normalized_name text := btrim(team_name);
|
|
created_team public.teams;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF normalized_name = '' OR char_length(normalized_name) > 80 THEN
|
|
RAISE EXCEPTION 'invalid_team_name' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(current_user_id::text, 73041));
|
|
IF (
|
|
SELECT count(*)
|
|
FROM public.teams
|
|
WHERE owner_id = current_user_id
|
|
) >= 25 THEN
|
|
RAISE EXCEPTION 'team_limit_reached' USING ERRCODE = '54000';
|
|
END IF;
|
|
|
|
INSERT INTO public.teams (name, owner_id)
|
|
VALUES (normalized_name, current_user_id)
|
|
RETURNING * INTO created_team;
|
|
|
|
INSERT INTO public.team_members (team_id, user_id, role)
|
|
VALUES (created_team.id, current_user_id, 'owner');
|
|
|
|
RETURN jsonb_build_object(
|
|
'id', created_team.id,
|
|
'name', created_team.name,
|
|
'owner_id', created_team.owner_id,
|
|
'role', 'owner',
|
|
'member_count', 1,
|
|
'created_at', created_team.created_at,
|
|
'updated_at', created_team.updated_at
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.create_team_invite(
|
|
target_team_id uuid,
|
|
invited_email text,
|
|
invited_role text DEFAULT 'member'
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
current_user_email text;
|
|
caller_team_role text;
|
|
normalized_email text := lower(btrim(invited_email));
|
|
existing_invite public.team_invites;
|
|
created_invite public.team_invites;
|
|
invite_token text;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF target_team_id IS NULL
|
|
OR normalized_email !~ '^[^[:space:]@]+@[^[:space:]@]+[.][^[:space:]@]+$'
|
|
OR char_length(normalized_email) > 254
|
|
OR invited_role NOT IN ('admin', 'member') THEN
|
|
RAISE EXCEPTION 'invalid_invite' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT lower(email) INTO current_user_email FROM auth.users WHERE id = current_user_id;
|
|
IF current_user_email = normalized_email THEN
|
|
RAISE EXCEPTION 'cannot_invite_self' USING ERRCODE = '22023';
|
|
END IF;
|
|
SELECT role INTO caller_team_role
|
|
FROM public.team_members
|
|
WHERE team_id = target_team_id AND user_id = current_user_id;
|
|
IF caller_team_role NOT IN ('owner', 'admin') THEN
|
|
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF invited_role = 'admin' AND caller_team_role <> 'owner' THEN
|
|
RAISE EXCEPTION 'owner_required_for_admin_invite' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text || ':' || normalized_email, 73042));
|
|
IF (
|
|
SELECT count(*)
|
|
FROM public.team_invites
|
|
WHERE invited_by = current_user_id
|
|
AND created_at >= now() - interval '24 hours'
|
|
) >= 50 THEN
|
|
RAISE EXCEPTION 'invite_rate_limited' USING ERRCODE = '54000';
|
|
END IF;
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM public.team_members AS member
|
|
JOIN auth.users AS account ON account.id = member.user_id
|
|
WHERE member.team_id = target_team_id AND lower(account.email) = normalized_email
|
|
) THEN
|
|
RAISE EXCEPTION 'already_team_member' USING ERRCODE = '23505';
|
|
END IF;
|
|
|
|
SELECT * INTO existing_invite
|
|
FROM public.team_invites
|
|
WHERE team_id = target_team_id
|
|
AND lower(email) = normalized_email
|
|
AND accepted_at IS NULL
|
|
ORDER BY created_at DESC
|
|
LIMIT 1
|
|
FOR UPDATE;
|
|
|
|
IF existing_invite.id IS NOT NULL AND existing_invite.expires_at > now() THEN
|
|
IF existing_invite.role <> invited_role THEN
|
|
UPDATE public.team_invites
|
|
SET role = invited_role
|
|
WHERE id = existing_invite.id
|
|
RETURNING * INTO existing_invite;
|
|
END IF;
|
|
RETURN jsonb_build_object(
|
|
'id', existing_invite.id,
|
|
'team_id', existing_invite.team_id,
|
|
'email', existing_invite.email,
|
|
'token', existing_invite.token,
|
|
'role', existing_invite.role,
|
|
'expires_at', existing_invite.expires_at,
|
|
'duplicate', true
|
|
);
|
|
END IF;
|
|
IF existing_invite.id IS NOT NULL THEN
|
|
DELETE FROM public.team_invites WHERE id = existing_invite.id;
|
|
END IF;
|
|
|
|
invite_token := public.generate_invite_token();
|
|
INSERT INTO public.team_invites (
|
|
team_id, invited_by, email, role, token
|
|
)
|
|
VALUES (
|
|
target_team_id, current_user_id, normalized_email, invited_role, invite_token
|
|
)
|
|
RETURNING * INTO created_invite;
|
|
|
|
RETURN jsonb_build_object(
|
|
'id', created_invite.id,
|
|
'team_id', created_invite.team_id,
|
|
'email', created_invite.email,
|
|
'token', created_invite.token,
|
|
'role', created_invite.role,
|
|
'expires_at', created_invite.expires_at,
|
|
'duplicate', false
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.accept_team_invite(invite_token text)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
current_email text;
|
|
invite public.team_invites;
|
|
final_role text;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF invite_token IS NULL OR char_length(invite_token) NOT BETWEEN 20 AND 128 THEN
|
|
RAISE EXCEPTION 'invalid_invite_token' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(invite_token, 73043));
|
|
SELECT * INTO invite
|
|
FROM public.team_invites
|
|
WHERE token = invite_token
|
|
FOR UPDATE;
|
|
IF invite.id IS NULL THEN
|
|
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
IF invite.accepted_at IS NOT NULL THEN
|
|
IF invite.accepted_by = current_user_id THEN
|
|
SELECT role INTO final_role
|
|
FROM public.team_members
|
|
WHERE team_id = invite.team_id AND user_id = current_user_id;
|
|
IF final_role IS NULL THEN
|
|
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
|
|
END IF;
|
|
RETURN jsonb_build_object(
|
|
'team_id', invite.team_id,
|
|
'role', final_role,
|
|
'duplicate', true,
|
|
'already_member', true
|
|
);
|
|
END IF;
|
|
RAISE EXCEPTION 'invite_already_accepted' USING ERRCODE = '23505';
|
|
END IF;
|
|
IF invite.expires_at <= now() THEN
|
|
RAISE EXCEPTION 'invite_expired' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT lower(email) INTO current_email FROM auth.users WHERE id = current_user_id;
|
|
IF current_email IS NULL OR current_email <> lower(invite.email) THEN
|
|
RAISE EXCEPTION 'invite_email_mismatch' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
INSERT INTO public.team_members (team_id, user_id, role)
|
|
VALUES (invite.team_id, current_user_id, invite.role)
|
|
ON CONFLICT (team_id, user_id) DO NOTHING;
|
|
|
|
SELECT role INTO final_role
|
|
FROM public.team_members
|
|
WHERE team_id = invite.team_id AND user_id = current_user_id
|
|
FOR UPDATE;
|
|
IF final_role IS NULL THEN
|
|
RAISE EXCEPTION 'team_membership_failed';
|
|
END IF;
|
|
|
|
UPDATE public.team_invites
|
|
SET accepted_at = now(), accepted_by = current_user_id
|
|
WHERE id = invite.id AND accepted_at IS NULL;
|
|
IF NOT FOUND THEN
|
|
RAISE EXCEPTION 'invite_acceptance_conflict';
|
|
END IF;
|
|
|
|
RETURN jsonb_build_object(
|
|
'team_id', invite.team_id,
|
|
'role', final_role,
|
|
'duplicate', false,
|
|
'already_member', false
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.update_team_member_role(
|
|
target_team_id uuid,
|
|
member_user_id uuid,
|
|
new_role text
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
caller_role text;
|
|
prior_role text;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF new_role NOT IN ('admin', 'member') THEN
|
|
RAISE EXCEPTION 'invalid_team_role' USING ERRCODE = '22023';
|
|
END IF;
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73044));
|
|
SELECT role INTO caller_role FROM public.team_members
|
|
WHERE team_id = target_team_id AND user_id = current_user_id;
|
|
IF caller_role <> 'owner' THEN
|
|
RAISE EXCEPTION 'team_owner_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
SELECT role INTO prior_role FROM public.team_members
|
|
WHERE team_id = target_team_id AND user_id = member_user_id
|
|
FOR UPDATE;
|
|
IF prior_role IS NULL THEN
|
|
RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
IF prior_role = 'owner' OR member_user_id = current_user_id THEN
|
|
RAISE EXCEPTION 'team_owner_immutable' USING ERRCODE = '42501';
|
|
END IF;
|
|
UPDATE public.team_members SET role = new_role
|
|
WHERE team_id = target_team_id AND user_id = member_user_id;
|
|
RETURN jsonb_build_object(
|
|
'team_id', target_team_id,
|
|
'user_id', member_user_id,
|
|
'role', new_role
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.remove_team_member(
|
|
target_team_id uuid,
|
|
member_user_id uuid
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
caller_role text;
|
|
target_role text;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(target_team_id::text, 73045));
|
|
SELECT role INTO caller_role FROM public.team_members
|
|
WHERE team_id = target_team_id AND user_id = current_user_id;
|
|
SELECT role INTO target_role FROM public.team_members
|
|
WHERE team_id = target_team_id AND user_id = member_user_id
|
|
FOR UPDATE;
|
|
IF target_role IS NULL THEN
|
|
RAISE EXCEPTION 'team_member_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
IF target_role = 'owner' THEN
|
|
RAISE EXCEPTION 'team_owner_cannot_leave' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF member_user_id <> current_user_id AND (
|
|
caller_role NOT IN ('owner', 'admin')
|
|
OR (target_role = 'admin' AND caller_role <> 'owner')
|
|
) THEN
|
|
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
|
|
DELETE FROM public.team_members
|
|
WHERE team_id = target_team_id AND user_id = member_user_id;
|
|
RETURN jsonb_build_object(
|
|
'team_id', target_team_id,
|
|
'user_id', member_user_id,
|
|
'removed', true
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.list_team_members(target_team_id uuid)
|
|
RETURNS TABLE (
|
|
user_id uuid,
|
|
role text,
|
|
joined_at timestamptz,
|
|
display_name text,
|
|
avatar_url text,
|
|
is_current boolean
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
SELECT
|
|
member.user_id,
|
|
member.role,
|
|
member.joined_at,
|
|
profile.name,
|
|
profile.avatar_url,
|
|
member.user_id = auth.uid()
|
|
FROM public.team_members AS member
|
|
LEFT JOIN public.profiles AS profile ON profile.id = member.user_id
|
|
WHERE member.team_id = target_team_id
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.team_members AS caller
|
|
WHERE caller.team_id = target_team_id AND caller.user_id = auth.uid()
|
|
)
|
|
ORDER BY
|
|
CASE member.role WHEN 'owner' THEN 0 WHEN 'admin' THEN 1 ELSE 2 END,
|
|
member.joined_at,
|
|
member.user_id;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.list_team_invites(target_team_id uuid)
|
|
RETURNS TABLE (
|
|
id uuid,
|
|
email text,
|
|
role text,
|
|
expires_at timestamptz,
|
|
created_at timestamptz
|
|
)
|
|
LANGUAGE sql
|
|
STABLE
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
SELECT invite.id, invite.email, invite.role, invite.expires_at, invite.created_at
|
|
FROM public.team_invites AS invite
|
|
WHERE invite.team_id = target_team_id
|
|
AND invite.accepted_at IS NULL
|
|
AND EXISTS (
|
|
SELECT 1 FROM public.team_members AS caller
|
|
WHERE caller.team_id = target_team_id
|
|
AND caller.user_id = auth.uid()
|
|
AND caller.role IN ('owner', 'admin')
|
|
)
|
|
ORDER BY invite.created_at DESC, invite.id;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.cancel_team_invite(invite_id uuid)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
invite public.team_invites;
|
|
caller_role text;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
SELECT * INTO invite FROM public.team_invites WHERE id = invite_id FOR UPDATE;
|
|
IF invite.id IS NULL OR invite.accepted_at IS NOT NULL THEN
|
|
RAISE EXCEPTION 'invite_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
SELECT role INTO caller_role FROM public.team_members
|
|
WHERE team_id = invite.team_id AND user_id = current_user_id;
|
|
IF invite.invited_by <> current_user_id AND caller_role NOT IN ('owner', 'admin') THEN
|
|
RAISE EXCEPTION 'team_admin_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
DELETE FROM public.team_invites WHERE id = invite.id;
|
|
RETURN jsonb_build_object('id', invite.id, 'cancelled', true);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.create_team(text) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.create_team_invite(uuid, text, text) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.accept_team_invite(text) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.update_team_member_role(uuid, uuid, text) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.remove_team_member(uuid, uuid) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.list_team_members(uuid) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.list_team_invites(uuid) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.cancel_team_invite(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.create_team(text) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.create_team_invite(uuid, text, text) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.accept_team_invite(text) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.update_team_member_role(uuid, uuid, text) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.remove_team_member(uuid, uuid) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.list_team_members(uuid) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.list_team_invites(uuid) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.cancel_team_invite(uuid) TO authenticated;
|
|
|
|
-- Replace the legacy Expo-only registration shape with provider-aware,
|
|
-- device-owned registrations. Old tokens cannot be proven to belong to a bare
|
|
-- RN installation, so they are invalidated and clients must register again.
|
|
DELETE FROM public.push_tokens;
|
|
UPDATE public.devices SET push_token = NULL WHERE push_token IS NOT NULL;
|
|
DROP INDEX IF EXISTS public.idx_devices_push_token;
|
|
DROP POLICY IF EXISTS "push_tokens_own" ON public.push_tokens;
|
|
|
|
ALTER TABLE public.push_tokens
|
|
ADD COLUMN IF NOT EXISTS provider text,
|
|
ADD COLUMN IF NOT EXISTS last_registered_at timestamptz NOT NULL DEFAULT now();
|
|
ALTER TABLE public.push_tokens
|
|
ALTER COLUMN device_id SET NOT NULL,
|
|
ALTER COLUMN provider SET NOT NULL,
|
|
ADD CONSTRAINT push_tokens_provider_check
|
|
CHECK (provider IN ('fcm', 'apns', 'webpush'));
|
|
|
|
CREATE POLICY "push_tokens_no_direct_access" ON public.push_tokens
|
|
FOR SELECT USING (false);
|
|
|
|
DROP POLICY IF EXISTS "devices_insert_own" ON public.devices;
|
|
CREATE POLICY "devices_insert_own" ON public.devices
|
|
FOR INSERT WITH CHECK (
|
|
user_id = auth.uid() AND revoked_at IS NULL AND push_token IS NULL
|
|
);
|
|
|
|
CREATE OR REPLACE FUNCTION public.reject_direct_device_push_token()
|
|
RETURNS trigger
|
|
LANGUAGE plpgsql
|
|
SET search_path = ''
|
|
AS $$
|
|
BEGIN
|
|
IF NEW.push_token IS DISTINCT FROM OLD.push_token THEN
|
|
RAISE EXCEPTION 'device_push_token_is_server_managed' USING ERRCODE = '42501';
|
|
END IF;
|
|
RETURN NEW;
|
|
END;
|
|
$$;
|
|
|
|
CREATE TRIGGER reject_direct_device_push_token_update
|
|
BEFORE UPDATE OF push_token ON public.devices
|
|
FOR EACH ROW EXECUTE FUNCTION public.reject_direct_device_push_token();
|
|
|
|
CREATE OR REPLACE FUNCTION public.register_push_registration(
|
|
push_device_id uuid,
|
|
push_provider text,
|
|
registration_id text
|
|
)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
owned_device public.devices;
|
|
existing_owner uuid;
|
|
registered public.push_tokens;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
IF push_provider NOT IN ('fcm', 'apns', 'webpush')
|
|
OR registration_id IS NULL
|
|
OR char_length(registration_id) NOT BETWEEN 20 AND 4096
|
|
OR registration_id ~ '[[:space:]]' THEN
|
|
RAISE EXCEPTION 'invalid_push_registration' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(push_device_id::text, 73046));
|
|
PERFORM pg_advisory_xact_lock(hashtextextended(registration_id, 73047));
|
|
SELECT * INTO owned_device
|
|
FROM public.devices
|
|
WHERE id = push_device_id AND user_id = current_user_id
|
|
FOR UPDATE;
|
|
IF owned_device.id IS NULL OR owned_device.revoked_at IS NOT NULL THEN
|
|
RAISE EXCEPTION 'active_device_not_found' USING ERRCODE = 'P0002';
|
|
END IF;
|
|
IF (owned_device.platform = 'android' AND push_provider <> 'fcm')
|
|
OR (owned_device.platform = 'ios' AND push_provider <> 'apns')
|
|
OR (owned_device.platform = 'web' AND push_provider <> 'webpush') THEN
|
|
RAISE EXCEPTION 'push_provider_platform_mismatch' USING ERRCODE = '22023';
|
|
END IF;
|
|
|
|
SELECT user_id INTO existing_owner
|
|
FROM public.push_tokens
|
|
WHERE token = registration_id
|
|
FOR UPDATE;
|
|
IF existing_owner IS NOT NULL AND existing_owner <> current_user_id THEN
|
|
RAISE EXCEPTION 'push_registration_owned_by_other_user' USING ERRCODE = '23505';
|
|
END IF;
|
|
|
|
DELETE FROM public.push_tokens
|
|
WHERE device_id = push_device_id OR token = registration_id;
|
|
INSERT INTO public.push_tokens (
|
|
user_id, token, platform, device_name, device_id, provider, last_registered_at
|
|
)
|
|
VALUES (
|
|
current_user_id,
|
|
registration_id,
|
|
owned_device.platform,
|
|
owned_device.device_name,
|
|
owned_device.id,
|
|
push_provider,
|
|
now()
|
|
)
|
|
RETURNING * INTO registered;
|
|
|
|
RETURN jsonb_build_object(
|
|
'device_id', registered.device_id,
|
|
'provider', registered.provider,
|
|
'registered_at', registered.last_registered_at
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
CREATE OR REPLACE FUNCTION public.unregister_push_registration(push_device_id uuid)
|
|
RETURNS jsonb
|
|
LANGUAGE plpgsql
|
|
SECURITY DEFINER
|
|
SET search_path = ''
|
|
AS $$
|
|
DECLARE
|
|
current_user_id uuid := auth.uid();
|
|
removed_count integer;
|
|
BEGIN
|
|
IF current_user_id IS NULL THEN
|
|
RAISE EXCEPTION 'authentication_required' USING ERRCODE = '42501';
|
|
END IF;
|
|
DELETE FROM public.push_tokens
|
|
WHERE device_id = push_device_id AND user_id = current_user_id;
|
|
GET DIAGNOSTICS removed_count = ROW_COUNT;
|
|
RETURN jsonb_build_object(
|
|
'device_id', push_device_id,
|
|
'removed', removed_count > 0
|
|
);
|
|
END;
|
|
$$;
|
|
|
|
REVOKE ALL ON FUNCTION public.register_push_registration(uuid, text, text) FROM PUBLIC, anon;
|
|
REVOKE ALL ON FUNCTION public.unregister_push_registration(uuid) FROM PUBLIC, anon;
|
|
GRANT EXECUTE ON FUNCTION public.register_push_registration(uuid, text, text) TO authenticated;
|
|
GRANT EXECUTE ON FUNCTION public.unregister_push_registration(uuid) TO authenticated;
|
|
|
|
COMMIT;
|