import { randomBytes } from "crypto" import { Prisma } from "@prisma/client" import { prisma } from "@/lib/prisma" /** Cookie carrying the invite token between /invite/ and signup. */ export const INVITE_COOKIE = "dt_invite" /** How long an invite token stays usable in the browser after the link is opened. */ export const INVITE_COOKIE_MAX_AGE = 30 * 60 export class InviteError extends Error { constructor( message: string, readonly status: number = 400 ) { super(message) this.name = "InviteError" } } /** 128 bits, same shape as the share-list slugs in api/shared-lists/route.ts. */ export function generateInviteToken(): string { return randomBytes(16).toString("hex") } export function inviteUrl(token: string, origin: string): string { return `${origin.replace(/\/$/, "")}/invite/${token}` } export type InviteState = "valid" | "unknown" | "revoked" | "expired" | "exhausted" /** * Read-only check, for rendering. Never rely on this to admit a signup - between the * check and the write another request can consume the last use. Use claimInvite for that. */ export async function inspectInvite(token: string): Promise<{ state: InviteState invite?: { id: string; label: string | null; createdById: string; inviterName: string | null } }> { if (!token || !/^[a-f0-9]{32}$/.test(token)) return { state: "unknown" } const invite = await prisma.invite.findUnique({ where: { token }, include: { createdBy: { select: { name: true } } }, }) if (!invite) return { state: "unknown" } if (invite.revokedAt) return { state: "revoked" } if (invite.expiresAt && invite.expiresAt <= new Date()) return { state: "expired" } if (invite.usedCount >= invite.maxUses) return { state: "exhausted" } return { state: "valid", invite: { id: invite.id, label: invite.label, createdById: invite.createdById, inviterName: invite.createdBy.name, }, } } /** * Atomically consume one use of an invite. Returns the invite, or null if it was not * claimable (unknown, revoked, expired, or already exhausted). * * The guard has to be a single UPDATE because Prisma's query builder cannot compare * two columns in a `where` (`usedCount < maxUses`). One statement means one row lock, * so two people redeeming the last use of a link cannot both succeed. * * Call inside a transaction alongside the user creation, so that a later failure * (duplicate email, for instance) rolls the consumed use back. */ export async function claimInvite(tx: Prisma.TransactionClient, token: string) { if (!token || !/^[a-f0-9]{32}$/.test(token)) return null const claimed = await tx.$executeRaw` UPDATE "Invite" SET "usedCount" = "usedCount" + 1, "updatedAt" = NOW() WHERE "token" = ${token} AND "revokedAt" IS NULL AND ("expiresAt" IS NULL OR "expiresAt" > NOW()) AND "usedCount" < "maxUses" ` if (claimed !== 1) return null return tx.invite.findUnique({ where: { token } }) }