Add roles and invite models

Additive schema only; no behaviour changes yet. Verified with prisma
migrate diff against production: two enums, three new tables, new User
columns and foreign keys, and no destructive statements.

Role lives on the User row rather than an OWNER_EMAIL env check, because
email is nullable and user-editable and so a poor thing to authorize
against. The jwt callback will need a per-request lookup for status
anyway, so reading role in the same query is free.

Invites are bearer tokens in a URL, shared out of band as a link or QR
code, because the app has no email capability. claimInvite consumes a
use with a single UPDATE guarded on usedCount < maxUses: Prisma cannot
compare two columns in a where clause, and one statement means one row
lock, so two people redeeming the last use cannot both succeed.

SearchCache finally gets its user relation. Every other user-owned model
cascades; without it, deleting a user left orphaned rows holding their
raw search queries. Verified zero orphans before adding the constraint.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
JP
2026-08-08 20:35:48 +00:00
parent 181ef13c7e
commit b3a0cc75eb
4 changed files with 258 additions and 35 deletions

88
src/lib/invites.ts Normal file
View File

@@ -0,0 +1,88 @@
import { randomBytes } from "crypto"
import { Prisma } from "@prisma/client"
import { prisma } from "@/lib/prisma"
/** Cookie carrying the invite token between /invite/<token> 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 } })
}