-- ============================================================ -- 019_invitation_rpcs.sql — peek + redeem invitation RPCs -- -- The third and last server-side migration in the multi-user -- accounts series. Both functions are SECURITY DEFINER for the -- same reason as the member RPCs in 018: the writes they need to -- do (or, for peek, the reads) cross RLS boundaries that the -- regular client policies (correctly) deny. -- -- peek_invitation — anonymous read. The /join/ page -- calls this to render "You're being invited to as -- " before the visitor signs in. Returns a uniform -- `{ ok, reason?, account_name?, role?, expires_at? }` JSON -- so the API route doesn't have to interpret error rows. -- -- redeem_invitation — authenticated. Atomically moves the caller -- from their just-created personal account to the inviter's -- account, cleans up the orphan personal account, and stamps -- the invitation accepted. Refuses if the caller's current -- account holds any domain data (to avoid silent data loss). -- -- Idempotent — safe to run multiple times. -- ============================================================ -- ============================================================ -- peek_invitation(p_token_hash text) -- -- Anonymous read by token hash. The plaintext token never -- reaches the DB; the route handler hashes it first. -- -- Returns a JSON object with one of two shapes: -- { "ok": true, "account_name": "...", "role": "...", -- "expires_at": "2026-..." } -- { "ok": false, "reason": "not_found" | "expired" | "used" } -- -- We could collapse all three failure cases to "not_found" to -- harden against enumeration, but the join page needs the -- distinction for UX ("This invite has expired — ask -- for a new one"). Tokens carry 256 bits of entropy, so the -- enumeration risk is theoretical; rate-limiting the route on -- the IP layer adds belt-and-braces. -- ============================================================ CREATE OR REPLACE FUNCTION public.peek_invitation( p_token_hash TEXT ) RETURNS JSON LANGUAGE plpgsql STABLE SECURITY DEFINER SET search_path = public AS $$ DECLARE v_inv account_invitations%ROWTYPE; v_account_name TEXT; BEGIN SELECT * INTO v_inv FROM account_invitations WHERE token_hash = p_token_hash; IF NOT FOUND THEN RETURN json_build_object('ok', false, 'reason', 'not_found'); END IF; IF v_inv.accepted_at IS NOT NULL THEN RETURN json_build_object('ok', false, 'reason', 'used'); END IF; IF v_inv.expires_at <= NOW() THEN RETURN json_build_object('ok', false, 'reason', 'expired'); END IF; SELECT name INTO v_account_name FROM accounts WHERE id = v_inv.account_id; RETURN json_build_object( 'ok', true, 'account_name', v_account_name, 'role', v_inv.role, 'expires_at', v_inv.expires_at ); END; $$; ALTER FUNCTION public.peek_invitation(TEXT) OWNER TO postgres; REVOKE ALL ON FUNCTION public.peek_invitation(TEXT) FROM PUBLIC; -- `anon` so the /join/ page can call this before the user -- signs in; `authenticated` so the same page works when already -- signed in (e.g. existing user clicks a forwarded link). GRANT EXECUTE ON FUNCTION public.peek_invitation(TEXT) TO anon, authenticated; -- ============================================================ -- redeem_invitation(p_token_hash text) -- -- Authenticated. The caller's auth.uid() is used both to scope -- the move ("which profile am I editing?") and as the safety -- check ("do you have any data we'd lose?"). -- -- Refusal codes (SQLSTATE): -- 22023 — invite invalid (not_found / used / expired) -- 42501 — caller not authenticated -- 23505 — caller's account has data (would be lost by joining) -- NOTE: we reuse Postgres's "unique_violation" code here -- rather than invent a custom SQLSTATE because there's -- no proper standard SQLSTATE for "conflict"; the route -- handler maps it to HTTP 409. -- -- Order of operations -- 1. Lock the invite row (FOR UPDATE) so two concurrent redeems -- of the same token can't both succeed. -- 2. Read caller's current account_id. -- 3. Verify caller is the sole owner of their current account -- AND that the account has zero domain rows. (If the caller -- already joined someone else's account once, their -- profile.account_id points there, not to a personal account -- they own — that case fails the "is owner" check and -- surfaces as 23505.) -- 4. Move profile.account_id + account_role to invite's. -- 5. Mark invitation accepted (token_hash stays, so the same -- token can't be re-used). -- 6. Delete the old personal account. The ON DELETE CASCADE on -- `accounts(id) ← profiles.account_id` would normally try to -- delete the caller's profile too, but step 4 already moved -- them to the new account, so the cascade is a no-op. -- ============================================================ CREATE OR REPLACE FUNCTION public.redeem_invitation( p_token_hash TEXT ) RETURNS UUID -- the joined account_id LANGUAGE plpgsql SECURITY DEFINER SET search_path = public AS $$ DECLARE v_caller_id UUID := auth.uid(); v_inv account_invitations%ROWTYPE; v_old_account_id UUID; v_old_account_owner UUID; v_has_data BOOLEAN; BEGIN IF v_caller_id IS NULL THEN RAISE EXCEPTION 'Unauthorized' USING ERRCODE = '42501'; END IF; SELECT * INTO v_inv FROM account_invitations WHERE token_hash = p_token_hash FOR UPDATE; IF NOT FOUND THEN RAISE EXCEPTION 'Invitation not found' USING ERRCODE = '22023'; END IF; IF v_inv.accepted_at IS NOT NULL THEN RAISE EXCEPTION 'Invitation has already been redeemed' USING ERRCODE = '22023'; END IF; IF v_inv.expires_at <= NOW() THEN RAISE EXCEPTION 'Invitation has expired' USING ERRCODE = '22023'; END IF; -- Caller's current account + its owner. SELECT p.account_id, a.owner_user_id INTO v_old_account_id, v_old_account_owner FROM profiles p JOIN accounts a ON a.id = p.account_id WHERE p.user_id = v_caller_id; IF v_old_account_id IS NULL THEN -- Defensive — every authenticated user has a profile post-017. RAISE EXCEPTION 'Caller has no profile' USING ERRCODE = '42501'; END IF; -- Edge case: the inviter sent themselves a link, or the -- caller is somehow already in the inviter's account. IF v_old_account_id = v_inv.account_id THEN RAISE EXCEPTION 'You are already a member of this account' USING ERRCODE = '23505'; END IF; -- Safety: the caller must be the SOLE OWNER of their current -- account (i.e. their fresh personal account from signup or a -- prior removal). Any other state means they're either: -- - a member of another shared account (joining a second -- would silently orphan their access to the first), or -- - the owner of an account with teammates (they'd abandon -- their team to join the inviter's). -- Either way, the safe answer is "make a different login". IF v_old_account_owner <> v_caller_id THEN RAISE EXCEPTION 'You are already in a shared account; sign up with a different email to join this one' USING ERRCODE = '23505'; END IF; -- Belt: even if they own their account, refuse if it has any -- domain data — joining would orphan their contacts, deals, -- broadcasts, automations, flows, templates, etc. SELECT EXISTS ( SELECT 1 FROM contacts WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM conversations WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM broadcasts WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM automations WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM flows WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM pipelines WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM message_templates WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM tags WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM custom_fields WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM contact_notes WHERE account_id = v_old_account_id UNION ALL SELECT 1 FROM whatsapp_config WHERE account_id = v_old_account_id LIMIT 1 ) INTO v_has_data; IF v_has_data THEN RAISE EXCEPTION 'Your account already contains data; sign up with a different email to join this one' USING ERRCODE = '23505'; END IF; -- Move the profile first so the cascade-on-delete of the old -- account doesn't try to nuke this user's profile too. UPDATE profiles SET account_id = v_inv.account_id, account_role = v_inv.role WHERE user_id = v_caller_id; UPDATE account_invitations SET accepted_at = NOW(), accepted_by_user_id = v_caller_id WHERE id = v_inv.id; -- Clean up the orphan personal account. Empty by the checks -- above, so this is purely housekeeping — no cascades fire -- because no other rows reference it. DELETE FROM accounts WHERE id = v_old_account_id; RETURN v_inv.account_id; END; $$; ALTER FUNCTION public.redeem_invitation(TEXT) OWNER TO postgres; REVOKE ALL ON FUNCTION public.redeem_invitation(TEXT) FROM PUBLIC; GRANT EXECUTE ON FUNCTION public.redeem_invitation(TEXT) TO authenticated;