mirror of
https://github.com/Luchexx/wacrm.git
synced 2026-11-02 17:29:45 +00:00
Merge pull request #171 from ArnasDon/feat/account-sharing-invitations-api
feat(api): invitation create + peek + redeem endpoints
This commit is contained in:
commit
e5d436e8e4
24
CHANGELOG.md
24
CHANGELOG.md
@ -56,6 +56,24 @@ as the sole owner of their own account and sees identical data.
|
|||||||
freshly-created personal account (mirror of the signup flow).
|
freshly-created personal account (mirror of the signup flow).
|
||||||
- `POST /api/account/transfer-ownership` — owner only. Atomic
|
- `POST /api/account/transfer-ownership` — owner only. Atomic
|
||||||
swap with the named member.
|
swap with the named member.
|
||||||
|
- **Invitation API + redeem flow** — the no-email, link-only
|
||||||
|
invite path. Backend is complete; the Members tab UI that
|
||||||
|
drives it lands in a follow-up.
|
||||||
|
- `GET /api/account/invitations` — list outstanding (admin+).
|
||||||
|
- `POST /api/account/invitations` — create an invite, returns
|
||||||
|
the plaintext token + share URL **exactly once** (we store
|
||||||
|
only the SHA-256 hash on the row). Body
|
||||||
|
`{ role, expiresInDays?, label? }`. Admin+.
|
||||||
|
- `DELETE /api/account/invitations/[id]` — revoke (admin+).
|
||||||
|
- `GET /api/invitations/[token]/peek` — public, per-IP
|
||||||
|
rate-limited. Returns `{ ok, account_name, role, expires_at }`
|
||||||
|
or `{ ok: false, reason }` so the join page can render
|
||||||
|
"You're being invited to <Account> as <Role>".
|
||||||
|
- `POST /api/invitations/[token]/redeem` — authenticated.
|
||||||
|
Atomically moves the caller's profile to the inviter's
|
||||||
|
account and cleans up the orphan personal account. Refuses
|
||||||
|
with 409 if the caller's current account already contains
|
||||||
|
domain data (no silent data loss).
|
||||||
|
|
||||||
### Migration required
|
### Migration required
|
||||||
|
|
||||||
@ -76,6 +94,12 @@ Apply against your Supabase project before deploying this version:
|
|||||||
role and raise SQLSTATE `42501` / `22023` on forbidden / bad
|
role and raise SQLSTATE `42501` / `22023` on forbidden / bad
|
||||||
input so the API layer can map cleanly to 403 / 400.
|
input so the API layer can map cleanly to 403 / 400.
|
||||||
Idempotent.
|
Idempotent.
|
||||||
|
- `supabase/migrations/019_invitation_rpcs.sql` — adds two
|
||||||
|
`SECURITY DEFINER` RPCs: `peek_invitation` (anonymous read by
|
||||||
|
token hash, returns a fixed-shape JSON envelope) and
|
||||||
|
`redeem_invitation` (authenticated atomic move + orphan
|
||||||
|
cleanup, with a domain-data safety check). Both bypass the
|
||||||
|
RLS that would otherwise block their reads/writes. Idempotent.
|
||||||
|
|
||||||
## [0.2.2] — 2026-05-29
|
## [0.2.2] — 2026-05-29
|
||||||
|
|
||||||
|
|||||||
61
src/app/api/account/invitations/[id]/route.ts
Normal file
61
src/app/api/account/invitations/[id]/route.ts
Normal file
@ -0,0 +1,61 @@
|
|||||||
|
// ============================================================
|
||||||
|
// DELETE /api/account/invitations/[id]
|
||||||
|
//
|
||||||
|
// Admin+. Revokes a pending invitation by id. RLS on
|
||||||
|
// `account_invitations` already restricts the DELETE to admins
|
||||||
|
// of the inviting account; we lean on it and skip the explicit
|
||||||
|
// ownership check.
|
||||||
|
//
|
||||||
|
// We intentionally delete the row outright rather than soft-
|
||||||
|
// deleting (a "revoked_at" flag). Once revoked, an invite is
|
||||||
|
// dead forever — there's no UX where a former invite should be
|
||||||
|
// listed; the plaintext token is gone too. Hard delete keeps
|
||||||
|
// the table small.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
_request: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> },
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const ctx = await requireRole("admin");
|
||||||
|
const { id } = await params;
|
||||||
|
|
||||||
|
// No `eq('account_id', ctx.accountId)` — the RLS policy
|
||||||
|
// (`is_account_member(account_id, 'admin')`) already scopes
|
||||||
|
// the DELETE to invites in the caller's account. Adding the
|
||||||
|
// filter would be redundant; omitting it surfaces a
|
||||||
|
// cross-account attempt as a silent 0-row delete (which is
|
||||||
|
// exactly what we want for a revocation endpoint).
|
||||||
|
const { error, count } = await ctx.supabase
|
||||||
|
.from("account_invitations")
|
||||||
|
.delete({ count: "exact" })
|
||||||
|
.eq("id", id);
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[DELETE /api/account/invitations/[id]] error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to revoke invitation" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count === 0) {
|
||||||
|
// Either the id doesn't exist or RLS hid it (different
|
||||||
|
// account). 404 either way — surfacing "exists but not
|
||||||
|
// yours" would leak existence.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Invitation not found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
144
src/app/api/account/invitations/route.ts
Normal file
144
src/app/api/account/invitations/route.ts
Normal file
@ -0,0 +1,144 @@
|
|||||||
|
// ============================================================
|
||||||
|
// /api/account/invitations
|
||||||
|
//
|
||||||
|
// GET — list outstanding (un-redeemed, non-expired) invites.
|
||||||
|
// POST — create a new invite link.
|
||||||
|
//
|
||||||
|
// Both admin+. The list endpoint is what the Members tab uses to
|
||||||
|
// populate the "Pending invitations" section; create is what the
|
||||||
|
// "Invite member" dialog calls.
|
||||||
|
//
|
||||||
|
// IMPORTANT: the plaintext token is returned exactly ONCE — in
|
||||||
|
// the POST response. We store only the SHA-256 hash on the row,
|
||||||
|
// so neither GET nor a future PATCH can ever resurface the
|
||||||
|
// link. The admin sees it in the creation modal, copies it, and
|
||||||
|
// shares it via WhatsApp/Slack/whatever they like. If they
|
||||||
|
// dismiss the modal without copying, the only recourse is to
|
||||||
|
// revoke and re-issue.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { requireRole, toErrorResponse } from "@/lib/auth/account";
|
||||||
|
import {
|
||||||
|
clampExpiryDays,
|
||||||
|
generateInviteToken,
|
||||||
|
inviteExpiresAt,
|
||||||
|
inviteUrl,
|
||||||
|
} from "@/lib/auth/invitations";
|
||||||
|
import { isAccountRole } from "@/lib/auth/roles";
|
||||||
|
|
||||||
|
// Resolve the base URL we publish invite links under. Mirrors the
|
||||||
|
// .env.local.example default so dev / preview / forks all behave
|
||||||
|
// without explicit config.
|
||||||
|
function getBaseUrl(): string {
|
||||||
|
return process.env.NEXT_PUBLIC_SITE_URL || "https://wacrm.tech";
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_LABEL_LEN = 80;
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const ctx = await requireRole("admin");
|
||||||
|
|
||||||
|
const { data, error } = await ctx.supabase
|
||||||
|
.from("account_invitations")
|
||||||
|
.select(
|
||||||
|
"id, role, label, created_by_user_id, created_at, expires_at, accepted_at, accepted_by_user_id",
|
||||||
|
)
|
||||||
|
.eq("account_id", ctx.accountId)
|
||||||
|
.is("accepted_at", null)
|
||||||
|
.gt("expires_at", new Date().toISOString())
|
||||||
|
.order("created_at", { ascending: false });
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[GET /api/account/invitations] fetch error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to load invitations" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ invitations: data ?? [] });
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: Request) {
|
||||||
|
try {
|
||||||
|
const ctx = await requireRole("admin");
|
||||||
|
|
||||||
|
const body = (await request.json().catch(() => null)) as
|
||||||
|
| { role?: unknown; expiresInDays?: unknown; label?: unknown }
|
||||||
|
| null;
|
||||||
|
|
||||||
|
const role = body?.role;
|
||||||
|
if (!isAccountRole(role) || role === "owner") {
|
||||||
|
// The DB CHECK already rejects 'owner', but failing fast
|
||||||
|
// here gives a clearer 400 than the eventual constraint
|
||||||
|
// violation surfaced as a 500.
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "'role' must be one of admin, agent, viewer" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const expiresInDaysRaw = body?.expiresInDays;
|
||||||
|
// `clampExpiryDays` tolerates undefined / NaN / negatives by
|
||||||
|
// collapsing to the safe default, so we just pass the raw
|
||||||
|
// value through after a type narrow.
|
||||||
|
const expiresInDays =
|
||||||
|
typeof expiresInDaysRaw === "number" ? expiresInDaysRaw : undefined;
|
||||||
|
const expiryDays = clampExpiryDays(expiresInDays);
|
||||||
|
const expiresAt = inviteExpiresAt(expiryDays);
|
||||||
|
|
||||||
|
let label: string | null = null;
|
||||||
|
if (typeof body?.label === "string") {
|
||||||
|
const trimmed = body.label.trim();
|
||||||
|
if (trimmed.length > MAX_LABEL_LEN) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: `Label must be ${MAX_LABEL_LEN} characters or fewer` },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
label = trimmed === "" ? null : trimmed;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { token, hash } = generateInviteToken();
|
||||||
|
|
||||||
|
const { data, error } = await ctx.supabase
|
||||||
|
.from("account_invitations")
|
||||||
|
.insert({
|
||||||
|
account_id: ctx.accountId,
|
||||||
|
token_hash: hash,
|
||||||
|
role,
|
||||||
|
created_by_user_id: ctx.userId,
|
||||||
|
label,
|
||||||
|
expires_at: expiresAt.toISOString(),
|
||||||
|
})
|
||||||
|
.select("id, role, label, expires_at, created_at")
|
||||||
|
.single();
|
||||||
|
|
||||||
|
if (error || !data) {
|
||||||
|
console.error("[POST /api/account/invitations] insert error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to create invitation" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(
|
||||||
|
{
|
||||||
|
invitation: data,
|
||||||
|
// Plaintext payload — visible to the admin exactly once.
|
||||||
|
token,
|
||||||
|
url: inviteUrl(token, getBaseUrl()),
|
||||||
|
expiresInDays: expiryDays,
|
||||||
|
},
|
||||||
|
{ status: 201 },
|
||||||
|
);
|
||||||
|
} catch (err) {
|
||||||
|
return toErrorResponse(err);
|
||||||
|
}
|
||||||
|
}
|
||||||
87
src/app/api/invitations/[token]/peek/route.ts
Normal file
87
src/app/api/invitations/[token]/peek/route.ts
Normal file
@ -0,0 +1,87 @@
|
|||||||
|
// ============================================================
|
||||||
|
// GET /api/invitations/[token]/peek
|
||||||
|
//
|
||||||
|
// Public — no auth required. Lets the /join/<token> page render
|
||||||
|
// "You're being invited to <Account> as <Role>" before the
|
||||||
|
// visitor signs up or signs in.
|
||||||
|
//
|
||||||
|
// Security model
|
||||||
|
// - Token is in the URL path, not the query, so it doesn't
|
||||||
|
// show up in standard access-log "referer" fields the way a
|
||||||
|
// `?token=` would.
|
||||||
|
// - The plaintext token never crosses the DB boundary — we
|
||||||
|
// hash it in TS first and look up by `token_hash`.
|
||||||
|
// - The peek RPC is SECURITY DEFINER so it bypasses the RLS
|
||||||
|
// that would otherwise block an anonymous SELECT on
|
||||||
|
// `account_invitations`. It returns a fixed-shape JSON
|
||||||
|
// payload that never leaks columns beyond what the join
|
||||||
|
// page renders.
|
||||||
|
// - Per-IP rate limit pinches brute-force enumeration of
|
||||||
|
// tokens. With 256 bits of entropy the enumeration risk is
|
||||||
|
// theoretical, but rate limiting is cheap insurance.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
|
import { hashInviteToken } from "@/lib/auth/invitations";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Best-effort client IP. The `x-forwarded-for` header is what
|
||||||
|
* every reverse proxy (Vercel, Hostinger, Cloudflare) sets when
|
||||||
|
* forwarding a request; we take the leftmost entry, which is
|
||||||
|
* the original client.
|
||||||
|
*
|
||||||
|
* Falls back to a constant when no proxy is in front (e.g.
|
||||||
|
* `localhost` during development) so rate-limit keys still
|
||||||
|
* exist — the limit then effectively applies "globally," which
|
||||||
|
* is fine for dev.
|
||||||
|
*/
|
||||||
|
function getClientIp(request: Request): string {
|
||||||
|
const xff = request.headers.get("x-forwarded-for");
|
||||||
|
if (xff) return xff.split(",")[0].trim();
|
||||||
|
const xri = request.headers.get("x-real-ip");
|
||||||
|
if (xri) return xri.trim();
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ token: string }> },
|
||||||
|
) {
|
||||||
|
// Rate-limit by IP first. Returns 429 to a serial bruteforcer
|
||||||
|
// before we ever touch the DB.
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const limit = checkRateLimit(`peek:${ip}`, RATE_LIMITS.invitationPeek);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
|
const { token } = await params;
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, reason: "not_found" },
|
||||||
|
{ status: 404 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
const { data, error } = await supabase.rpc("peek_invitation", {
|
||||||
|
p_token_hash: hashInviteToken(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
console.error("[peek] rpc error:", error);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ ok: false, reason: "server_error" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// The RPC always returns a json object — either ok:true with
|
||||||
|
// metadata or ok:false with a reason. Forward verbatim.
|
||||||
|
return NextResponse.json(data);
|
||||||
|
}
|
||||||
91
src/app/api/invitations/[token]/redeem/route.ts
Normal file
91
src/app/api/invitations/[token]/redeem/route.ts
Normal file
@ -0,0 +1,91 @@
|
|||||||
|
// ============================================================
|
||||||
|
// POST /api/invitations/[token]/redeem
|
||||||
|
//
|
||||||
|
// Authenticated. Caller atomically moves from their personal
|
||||||
|
// account (created at signup) to the inviter's account with the
|
||||||
|
// invite's role. Heavy lifting lives in the SECURITY DEFINER
|
||||||
|
// `redeem_invitation` RPC from migration 019.
|
||||||
|
//
|
||||||
|
// Refusal contract (from the RPC)
|
||||||
|
// - SQLSTATE 42501 → 401 (caller not authenticated)
|
||||||
|
// - SQLSTATE 22023 → 400 (invitation not_found / used / expired)
|
||||||
|
// - SQLSTATE 23505 → 409 (caller's account already has data /
|
||||||
|
// they're already in this or another shared account)
|
||||||
|
//
|
||||||
|
// Rate limit (per IP) is the same shape as peek but tighter —
|
||||||
|
// a successful redeem changes data, and the RPC's data-loss
|
||||||
|
// guard makes brute-force retries pointless past a few attempts.
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { PostgrestError } from "@supabase/supabase-js";
|
||||||
|
|
||||||
|
import { hashInviteToken } from "@/lib/auth/invitations";
|
||||||
|
import {
|
||||||
|
checkRateLimit,
|
||||||
|
rateLimitResponse,
|
||||||
|
RATE_LIMITS,
|
||||||
|
} from "@/lib/rate-limit";
|
||||||
|
import { createClient } from "@/lib/supabase/server";
|
||||||
|
|
||||||
|
function getClientIp(request: Request): string {
|
||||||
|
const xff = request.headers.get("x-forwarded-for");
|
||||||
|
if (xff) return xff.split(",")[0].trim();
|
||||||
|
const xri = request.headers.get("x-real-ip");
|
||||||
|
if (xri) return xri.trim();
|
||||||
|
return "unknown";
|
||||||
|
}
|
||||||
|
|
||||||
|
function rpcErrorToResponse(err: PostgrestError): NextResponse {
|
||||||
|
if (err.code === "42501") {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 401 });
|
||||||
|
}
|
||||||
|
if (err.code === "22023") {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 400 });
|
||||||
|
}
|
||||||
|
if (err.code === "23505") {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: 409 });
|
||||||
|
}
|
||||||
|
console.error("[redeem] unexpected RPC error:", err);
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Failed to redeem invitation" },
|
||||||
|
{ status: 500 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: Request,
|
||||||
|
{ params }: { params: Promise<{ token: string }> },
|
||||||
|
) {
|
||||||
|
const ip = getClientIp(request);
|
||||||
|
const limit = checkRateLimit(`redeem:${ip}`, RATE_LIMITS.invitationRedeem);
|
||||||
|
if (!limit.success) return rateLimitResponse(limit);
|
||||||
|
|
||||||
|
const { token } = await params;
|
||||||
|
if (!token || typeof token !== "string") {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: "Missing invitation token" },
|
||||||
|
{ status: 400 },
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const supabase = await createClient();
|
||||||
|
|
||||||
|
// The RPC checks `auth.uid()` itself, but failing fast here
|
||||||
|
// gives a cleaner 401 without a Supabase round trip on the
|
||||||
|
// common "user clicked the link before logging in" path.
|
||||||
|
const {
|
||||||
|
data: { user },
|
||||||
|
} = await supabase.auth.getUser();
|
||||||
|
if (!user) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { data: accountId, error } = await supabase.rpc("redeem_invitation", {
|
||||||
|
p_token_hash: hashInviteToken(token),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (error) return rpcErrorToResponse(error);
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true, accountId });
|
||||||
|
}
|
||||||
@ -125,6 +125,15 @@ export const RATE_LIMITS = {
|
|||||||
* fidget with reactions and a single "swap" is actually two calls
|
* fidget with reactions and a single "swap" is actually two calls
|
||||||
* (remove + add) under the hood. */
|
* (remove + add) under the hood. */
|
||||||
react: { limit: 120, windowMs: 60_000 },
|
react: { limit: 120, windowMs: 60_000 },
|
||||||
|
/** Invitation peek (public, per-IP). 30/min lets a forwarded link
|
||||||
|
* retry a handful of times under flaky connectivity without
|
||||||
|
* enabling brute-force token enumeration. With 256-bit tokens the
|
||||||
|
* enumeration risk is theoretical; this is belt-and-braces. */
|
||||||
|
invitationPeek: { limit: 30, windowMs: 60_000 },
|
||||||
|
/** Invitation redeem (authed, per-IP+user). Tighter than peek —
|
||||||
|
* successful redemption mutates two profiles and an invite row, so
|
||||||
|
* the abuse surface is "spam join attempts." */
|
||||||
|
invitationRedeem: { limit: 10, windowMs: 60_000 },
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
/** Test-only helper. Clears the in-memory state so unit tests don't
|
/** Test-only helper. Clears the in-memory state so unit tests don't
|
||||||
|
|||||||
237
supabase/migrations/019_invitation_rpcs.sql
Normal file
237
supabase/migrations/019_invitation_rpcs.sql
Normal file
@ -0,0 +1,237 @@
|
|||||||
|
-- ============================================================
|
||||||
|
-- 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;
|
||||||
Loading…
Reference in New Issue
Block a user