wacrm/supabase/migrations/019_invitation_rpcs.sql
Arnas Donauskas b013861160
feat(api): invitation create + peek + redeem endpoints
PR 4 of the multi-user accounts series. Lands the link-only
invitation flow — admin generates a shareable URL, copies it,
sends it via any channel. No email service needed.

API surface
- GET    /api/account/invitations              — list pending  (admin+)
- POST   /api/account/invitations              — create        (admin+)
- DELETE /api/account/invitations/[id]         — revoke        (admin+)
- GET    /api/invitations/[token]/peek         — public, rate-limited per-IP
- POST   /api/invitations/[token]/redeem       — authenticated, rate-limited per-IP

Token lifecycle
- POST returns the plaintext token + share URL exactly ONCE in
  the response. We store only the SHA-256 hash. There is no
  endpoint that resurfaces it; if the admin dismisses the dialog
  without copying, the only recourse is to revoke and re-issue.
- Peek and redeem take the plaintext token in the URL path, hash
  it in TS via `hashInviteToken`, and look up by `token_hash`.

Two new SECURITY DEFINER RPCs back the cross-RLS work
(migration 019):
- `peek_invitation` — anonymous read. Returns a uniform JSON
  envelope: `{ ok, account_name, role, expires_at }` or
  `{ ok: false, reason: not_found|used|expired }`. The route
  forwards verbatim so the join page can branch cleanly.
- `redeem_invitation` — authenticated. Locks the invite row
  `FOR UPDATE`, validates expiry/usage, refuses if the caller is
  already in a shared account OR their personal account has any
  domain rows (checked across all 11 user-creatable parent
  tables), then atomically moves profile.account_id +
  account_role, stamps the invitation accepted, and deletes the
  orphan personal account. Single transaction.

Error contract
- 22023 (invalid_parameter_value)  → 400 — not_found / used / expired
- 42501 (insufficient_privilege)   → 401 — caller not authenticated
- 23505 (unique_violation, reused) → 409 — caller's account has data,
                                           or they're already a
                                           member of the inviter's
                                           account / another shared
                                           account

Rate limiting
- Two new buckets in `RATE_LIMITS`: invitationPeek (30/min/IP)
  and invitationRedeem (10/min/IP). Belt-and-braces against
  token enumeration even though 256-bit entropy makes it
  theoretical. Uses the existing fixed-window in-memory limiter.

Why no peek-then-redeem two-step on the server
- Peek is read-only and idempotent; redeem locks `FOR UPDATE` so
  two concurrent redemptions can't both win. The peek result is
  cosmetic ("show me what I'm joining"); the actual write
  happens entirely inside the redeem RPC's transaction, so a
  TOCTOU between peek and redeem just means the user sees the
  same `{ ok: false, reason }` on the next render.

Verification
- `npm run lint`      → 0 errors (same 20 pre-existing warnings)
- `npm run typecheck` → clean
- `npm test`          → 366/366 pass
- `npm run build`     → succeeds; the 4 new routes register as ƒ

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 17:32:46 +03:00

238 lines
9.1 KiB
PL/PgSQL

-- ============================================================
-- 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/<token> page
-- calls this to render "You're being invited to <Account> as
-- <Role>" 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 <name>
-- 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/<token> 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;