diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 53169f9..1bd826b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -9,16 +9,37 @@ datasource db { // ─── Auth.js Models ────────────────────────────────────────────── +enum UserRole { + OWNER + MEMBER +} + +enum UserStatus { + ACTIVE + SUSPENDED +} + model User { id String @id @default(cuid()) name String? email String? @unique emailVerified DateTime? image String? - password String? // hashed password for credentials auth + password String? // hashed password for credentials auth createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // Authorization is on this column, not on an OWNER_EMAIL env check: email is + // nullable and user-editable, so it is a poor thing to authorize against. + role UserRole @default(MEMBER) + status UserStatus @default(ACTIVE) + invitedById String? + + // Per-member AI controls. aiDailyBudgetUsd null means "use the deployment default" + // when minting that member's gateway key. + aiEnabled Boolean @default(true) + aiDailyBudgetUsd Float? + accounts Account[] sessions Session[] drinks Drink[] @@ -31,6 +52,62 @@ model User { barItems BarItem[] recipes Recipe[] flavorProfile FlavorProfile? + searchCache SearchCache[] + + invitesCreated Invite[] @relation("InviteCreator") + redemption InviteRedemption? + passwordResets PasswordReset[] +} + +// ─── Invites ───────────────────────────────────────────────────── +// Signup is invite-only. There is no email capability in this app, so an invite is +// a bearer token in a URL that the owner shares out of band (link or QR code). + +model Invite { + id String @id @default(cuid()) + token String @unique // randomBytes(16).toString("hex") + label String? // free text so the owner remembers who it was for + createdById String + maxUses Int @default(1) + usedCount Int @default(0) + expiresAt DateTime? + revokedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + createdBy User @relation("InviteCreator", fields: [createdById], references: [id], onDelete: Cascade) + redemptions InviteRedemption[] + + @@index([createdById]) +} + +model InviteRedemption { + id String @id @default(cuid()) + inviteId String + userId String @unique // a user is created by at most one invite + email String? + provider String // "credentials" | "oauth" + createdAt DateTime @default(now()) + + invite Invite @relation(fields: [inviteId], references: [id], onDelete: Cascade) + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([inviteId]) +} + +// Owner-generated, single-use password reset. Delivered out of band like an invite, +// because there is no email infrastructure to send one. +model PasswordReset { + id String @id @default(cuid()) + token String @unique + userId String + expiresAt DateTime + usedAt DateTime? + createdAt DateTime @default(now()) + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) } model Account { @@ -72,15 +149,15 @@ model VerificationToken { // ─── App Models ────────────────────────────────────────────────── model UserApiKey { - id String @id @default(cuid()) - userId String - provider String // "switchboard" (legacy rows may be "claude" | "openai") - encryptedKey String @db.Text - iv String // initialization vector for decryption - label String? // optional user-friendly label - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + provider String // "switchboard" (legacy rows may be "claude" | "openai") + encryptedKey String @db.Text + iv String // initialization vector for decryption + label String? // optional user-friendly label + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) @@ -94,7 +171,7 @@ model UserPreference { avoidedStyles String[] // e.g., ["Sour", "Light Lager"] minAbv Float? maxAbv Float? - defaultProvider String? // deprecated and unused; kept so old backups still restore + defaultProvider String? // deprecated and unused; kept so old backups still restore createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -114,8 +191,8 @@ model Drink { userId String name String type DrinkType - subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon" - brewery String? // brewery or winery + subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon" + brewery String? // brewery or winery region String? abv Float? description String? @db.Text @@ -137,10 +214,10 @@ model Rating { id String @id @default(cuid()) userId String drinkId String - score Int // 1-5 + score Int // 1-5 notes String? @db.Text wouldReorder Boolean @default(false) - location String? // where they tried it + location String? // where they tried it createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -159,16 +236,16 @@ enum ScanStatus { } model MenuScan { - id String @id @default(cuid()) - userId String - imageUrl String - status ScanStatus @default(UPLOADING) - aiProvider String? // "switchboard:" — the model the gateway routed to - aiRawResponse Json? // raw AI response for debugging - errorMessage String? @db.Text - processedAt DateTime? - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + userId String + imageUrl String + status ScanStatus @default(UPLOADING) + aiProvider String? // "switchboard:" — the model the gateway routed to + aiRawResponse Json? // raw AI response for debugging + errorMessage String? @db.Text + processedAt DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) items MenuItem[] @@ -187,7 +264,7 @@ model MenuItem { price String? description String? @db.Text matchedDrinkId String? - userRating Int? // cached rating from matched drink + userRating Int? // cached rating from matched drink aiRecommended Boolean @default(false) aiReason String? @db.Text // why AI recommends it createdAt DateTime @default(now()) @@ -209,8 +286,8 @@ model WishlistItem { brewery String? abv Float? description String? @db.Text - notes String? @db.Text // user's personal note ("saw at Joe's bar") - source String? // "scan", "ai_search", "manual" + notes String? @db.Text // user's personal note ("saw at Joe's bar") + source String? // "scan", "ai_search", "manual" createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -224,12 +301,16 @@ model WishlistItem { model SearchCache { id String @id @default(cuid()) userId String - queryHash String // normalized (lowercase, trimmed) - query String // original text - results Json // { drinks: [...] } - provider String // "switchboard" + queryHash String // normalized (lowercase, trimmed) + query String // original text + results Json // { drinks: [...] } + provider String // "switchboard" createdAt DateTime @default(now()) + // Every other user-owned model cascades; without this, deleting a user left + // orphaned rows holding their raw search queries and AI results. + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + @@unique([userId, queryHash, provider]) @@index([userId]) } @@ -239,7 +320,7 @@ model SearchCache { model SharedList { id String @id @default(cuid()) userId String - slug String @unique // public URL slug + slug String @unique // public URL slug title String description String? @db.Text listType String @default("collection") // "collection", "wishlist", "custom" @@ -297,8 +378,8 @@ model Recipe { id String @id @default(cuid()) userId String title String - ingredients Json // [{ name: string, amount: string, available: boolean }] - steps Json // string[] + ingredients Json // [{ name: string, amount: string, available: boolean }] + steps Json // string[] garnish String? glassware String? sourceDrinkId String? diff --git a/src/lib/authz.ts b/src/lib/authz.ts new file mode 100644 index 0000000..ce728ac --- /dev/null +++ b/src/lib/authz.ts @@ -0,0 +1,40 @@ +import { NextResponse } from "next/server" +import { auth } from "@/lib/auth" +import type { Session } from "next-auth" + +/** + * Authorization helpers for route handlers. + * + * Each returns either the session or a Response to return directly: + * + * const result = await requireOwner() + * if (result instanceof NextResponse) return result + * // result is a Session from here on + * + * 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. + */ + +export function isOwner(session: Session | null): boolean { + return session?.user?.role === "OWNER" +} + +export async function requireUser(): Promise { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + return session +} + +export async function requireOwner(): Promise { + const session = await auth() + if (!session?.user?.id) { + return NextResponse.json({ error: "Unauthorized" }, { status: 401 }) + } + if (!isOwner(session)) { + // 404 rather than 403 so the existence of owner-only endpoints is not confirmed. + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + return session +} diff --git a/src/lib/invites.ts b/src/lib/invites.ts new file mode 100644 index 0000000..83a5f62 --- /dev/null +++ b/src/lib/invites.ts @@ -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/ 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 } }) +} diff --git a/src/types/next-auth.d.ts b/src/types/next-auth.d.ts index 26fa2d2..c5bd398 100644 --- a/src/types/next-auth.d.ts +++ b/src/types/next-auth.d.ts @@ -4,6 +4,20 @@ declare module "next-auth" { interface Session { user: { id: string + /** + * Populated by the session callback in src/lib/auth.ts from a per-request + * lookup. Optional because a token minted before roles existed will not + * carry one until it is next refreshed. + */ + role?: "OWNER" | "MEMBER" } & DefaultSession["user"] } } + +declare module "next-auth/jwt" { + interface JWT { + id?: string + role?: "OWNER" | "MEMBER" + status?: "ACTIVE" | "SUSPENDED" + } +}