From e0e1ac49d90d4f6ea5b658d08e75836d1c401bb3 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Fri, 3 Jul 2026 21:36:40 +0200 Subject: [PATCH] feat: signup toggle, invite links, admin-created users MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - New invites table: token-gated signup, optional email lock, role/tier override, single-use, expiry. - SIGNUPS_DISABLED site setting toggle at /admin/settings. - databaseHooks.user.create gate in auth/server.ts blocks new account creation (email + Google OAuth) when disabled unless a valid invite cookie is present; applies invite role/tier and marks it consumed. - /admin/invites: create/list/revoke shareable invite links. - /admin/users: "Create user" dialog — admin sets email/role/tier, account is pre-verified, user gets a set-password email (admin never sees a password). - Signup page reads ?invite=, validates via public /api/v1/invites/[token], locks the form when signups are closed and no valid invite is present. - proxy.ts: allowlist /api/v1/invites/ for anonymous invite checks. Co-Authored-By: Claude Sonnet 5 --- apps/web/app/(auth)/signup/page.tsx | 93 +- apps/web/app/(auth)/signup/signup-form.tsx | 164 + apps/web/app/admin/invites/page.tsx | 34 + apps/web/app/admin/layout.tsx | 3 +- apps/web/app/admin/settings/page.tsx | 6 +- apps/web/app/admin/users/page.tsx | 6 +- .../app/api/v1/admin/invites/[id]/route.ts | 30 + apps/web/app/api/v1/admin/invites/route.ts | 57 + apps/web/app/api/v1/admin/settings/route.ts | 1 + apps/web/app/api/v1/admin/users/route.ts | 100 + apps/web/app/api/v1/invites/[token]/route.ts | 15 + .../components/admin/create-user-dialog.tsx | 100 + apps/web/components/admin/invites-manager.tsx | 157 + apps/web/components/admin/signups-toggle.tsx | 55 + apps/web/lib/auth/server.ts | 20 +- apps/web/lib/invites.ts | 53 + apps/web/lib/site-settings.ts | 7 +- apps/web/proxy.ts | 2 +- .../src/migrations/0015_aromatic_lester.sql | 16 + .../db/src/migrations/meta/0015_snapshot.json | 3625 +++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 7 + packages/db/src/schema/tiers.ts | 22 +- 22 files changed, 4483 insertions(+), 90 deletions(-) create mode 100644 apps/web/app/(auth)/signup/signup-form.tsx create mode 100644 apps/web/app/admin/invites/page.tsx create mode 100644 apps/web/app/api/v1/admin/invites/[id]/route.ts create mode 100644 apps/web/app/api/v1/admin/invites/route.ts create mode 100644 apps/web/app/api/v1/admin/users/route.ts create mode 100644 apps/web/app/api/v1/invites/[token]/route.ts create mode 100644 apps/web/components/admin/create-user-dialog.tsx create mode 100644 apps/web/components/admin/invites-manager.tsx create mode 100644 apps/web/components/admin/signups-toggle.tsx create mode 100644 apps/web/lib/invites.ts create mode 100644 packages/db/src/migrations/0015_aromatic_lester.sql create mode 100644 packages/db/src/migrations/meta/0015_snapshot.json diff --git a/apps/web/app/(auth)/signup/page.tsx b/apps/web/app/(auth)/signup/page.tsx index 6ebce31..5680a93 100644 --- a/apps/web/app/(auth)/signup/page.tsx +++ b/apps/web/app/(auth)/signup/page.tsx @@ -1,86 +1,13 @@ -"use client"; +import { isSignupsDisabled } from "@/lib/site-settings"; +import { SignupForm } from "./signup-form"; -import { useState } from "react"; -import Link from "next/link"; -import { useRouter } from "next/navigation"; -import { toast } from "sonner"; -import { useTranslations } from "next-intl"; -import { authClient } from "@/lib/auth/client"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Label } from "@/components/ui/label"; -import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; -import { Separator } from "@/components/ui/separator"; +export default async function SignupPage({ + searchParams, +}: { + searchParams: Promise<{ invite?: string }>; +}) { + const { invite } = await searchParams; + const signupsDisabled = await isSignupsDisabled(); -export default function SignupPage() { - const router = useRouter(); - const t = useTranslations("auth"); - const [name, setName] = useState(""); - const [email, setEmail] = useState(""); - const [password, setPassword] = useState(""); - const [loading, setLoading] = useState(false); - - async function handleSubmit(e: React.FormEvent) { - e.preventDefault(); - setLoading(true); - const { error } = await authClient.signUp.email({ - name, - email, - password, - callbackURL: "/recipes", - }); - setLoading(false); - if (error) { - toast.error(error.message ?? "Sign up failed"); - } else { - toast.success("Account created — check your email to verify"); - router.push("/login"); - } - } - - async function handleGoogle() { - await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" }); - } - - return ( - - - {t("signUpTitle")} - {t("signUpSubtitle")} - -
- - -
- - {t("or")} - -
-
- - setName(e.target.value)} required /> -
-
- - setEmail(e.target.value)} required /> -
-
- - setPassword(e.target.value)} required minLength={8} /> -
- -
-
- -

- {t("alreadyHaveAccount")}{" "} - {t("signIn")} -

-
-
- ); + return ; } diff --git a/apps/web/app/(auth)/signup/signup-form.tsx b/apps/web/app/(auth)/signup/signup-form.tsx new file mode 100644 index 0000000..5ab1094 --- /dev/null +++ b/apps/web/app/(auth)/signup/signup-form.tsx @@ -0,0 +1,164 @@ +"use client"; + +import { useEffect, useState } from "react"; +import Link from "next/link"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { useTranslations } from "next-intl"; +import { authClient } from "@/lib/auth/client"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"; +import { Separator } from "@/components/ui/separator"; + +const INVITE_COOKIE = "epicure_invite"; + +export function SignupForm({ signupsDisabled, inviteToken }: { signupsDisabled: boolean; inviteToken: string | null }) { + const router = useRouter(); + const t = useTranslations("auth"); + const [name, setName] = useState(""); + const [email, setEmail] = useState(""); + const [password, setPassword] = useState(""); + const [loading, setLoading] = useState(false); + const [inviteState, setInviteState] = useState<"checking" | "valid" | "invalid" | "none">( + inviteToken ? "checking" : "none" + ); + const [inviteEmail, setInviteEmail] = useState(null); + + useEffect(() => { + if (!inviteToken) return; + fetch(`/api/v1/invites/${inviteToken}`) + .then((res) => res.json()) + .then((data: { valid: boolean; email: string | null }) => { + setInviteState(data.valid ? "valid" : "invalid"); + if (data.valid && data.email) { + setInviteEmail(data.email); + setEmail(data.email); + } + }) + .catch(() => setInviteState("invalid")); + }, [inviteToken]); + + function setInviteCookie() { + if (inviteToken && inviteState === "valid") { + document.cookie = `${INVITE_COOKIE}=${inviteToken}; path=/; max-age=600; samesite=lax`; + } + } + + const locked = signupsDisabled && inviteState !== "valid"; + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault(); + setLoading(true); + setInviteCookie(); + const { error } = await authClient.signUp.email({ + name, + email, + password, + callbackURL: "/recipes", + }); + setLoading(false); + if (error) { + toast.error(error.message ?? "Sign up failed"); + } else { + toast.success("Account created — check your email to verify"); + router.push("/login"); + } + } + + async function handleGoogle() { + setInviteCookie(); + await authClient.signIn.social({ provider: "google", callbackURL: "/recipes" }); + } + + if (signupsDisabled && inviteState === "checking") { + return ( + + Checking invite… + + ); + } + + if (signupsDisabled && inviteState === "none") { + return ( + + + Signups are closed + Epicure isn't accepting new accounts right now. You'll need an invite link. + + + + Back to login + + + + ); + } + + if (signupsDisabled && inviteState === "invalid") { + return ( + + + Invite invalid or expired + This invite link no longer works. Ask whoever sent it for a new one. + + + + Back to login + + + + ); + } + + return ( + + + {t("signUpTitle")} + {t("signUpSubtitle")} + +
+ + +
+ + {t("or")} + +
+
+ + setName(e.target.value)} required /> +
+
+ + setEmail(e.target.value)} + readOnly={!!inviteEmail} + required + /> +
+
+ + setPassword(e.target.value)} required minLength={8} /> +
+ +
+
+ +

+ {t("alreadyHaveAccount")}{" "} + {t("signIn")} +

+
+
+ ); +} diff --git a/apps/web/app/admin/invites/page.tsx b/apps/web/app/admin/invites/page.tsx new file mode 100644 index 0000000..529e786 --- /dev/null +++ b/apps/web/app/admin/invites/page.tsx @@ -0,0 +1,34 @@ +import type { Metadata } from "next"; +import { listInvites } from "@/lib/invites"; +import { InvitesManager } from "@/components/admin/invites-manager"; + +export const metadata: Metadata = { title: "Invites – Admin" }; + +export default async function AdminInvitesPage() { + const invites = await listInvites(); + const appUrl = process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"; + + return ( +
+
+

Invites

+

+ Generate shareable signup links. Required when signups are disabled in{" "} + Settings. +

+
+ ({ + id: i.id, + token: i.token, + email: i.email, + role: i.role, + tier: i.tier, + createdAt: i.createdAt.toISOString(), + expiresAt: i.expiresAt?.toISOString() ?? null, + }))} + appUrl={appUrl} + /> +
+ ); +} diff --git a/apps/web/app/admin/layout.tsx b/apps/web/app/admin/layout.tsx index d4bac18..3763e39 100644 --- a/apps/web/app/admin/layout.tsx +++ b/apps/web/app/admin/layout.tsx @@ -3,12 +3,13 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; import { db, users, eq } from "@epicure/db"; import Link from "next/link"; -import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge } from "lucide-react"; +import { Shield, Users, BookOpen, Settings, BarChart3, ClipboardList, HardDrive, Bot, ArrowLeft, Gauge, Mail } from "lucide-react"; import { cn } from "@/lib/utils"; const adminNav = [ { href: "/admin", label: "Overview", icon: BarChart3 }, { href: "/admin/users", label: "Users", icon: Users }, + { href: "/admin/invites", label: "Invites", icon: Mail }, { href: "/admin/recipes", label: "Recipes", icon: BookOpen }, { href: "/admin/tiers", label: "Tier Limits", icon: Gauge }, { href: "/admin/audit-logs", label: "Audit Logs", icon: ClipboardList }, diff --git a/apps/web/app/admin/settings/page.tsx b/apps/web/app/admin/settings/page.tsx index d88fcb6..c63011c 100644 --- a/apps/web/app/admin/settings/page.tsx +++ b/apps/web/app/admin/settings/page.tsx @@ -1,6 +1,7 @@ import type { Metadata } from "next"; -import { getAllSiteSettings } from "@/lib/site-settings"; +import { getAllSiteSettings, isSignupsDisabled } from "@/lib/site-settings"; import { AdminSettingsForm } from "@/components/admin/admin-settings-form"; +import { SignupsToggle } from "@/components/admin/signups-toggle"; export const metadata: Metadata = { title: "Site Settings – Admin" }; @@ -24,6 +25,7 @@ const SETTING_GROUPS = [ export default async function AdminSettingsPage() { const settings = await getAllSiteSettings(); + const signupsDisabled = await isSignupsDisabled(); return (
@@ -35,6 +37,8 @@ export default async function AdminSettingsPage() {

+ + {SETTING_GROUPS.map((group) => ( ))} diff --git a/apps/web/app/admin/users/page.tsx b/apps/web/app/admin/users/page.tsx index 3fcb2e7..cff8157 100644 --- a/apps/web/app/admin/users/page.tsx +++ b/apps/web/app/admin/users/page.tsx @@ -4,6 +4,7 @@ import { users } from "@epicure/db"; import { desc } from "@epicure/db"; import { Badge } from "@/components/ui/badge"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; +import { CreateUserDialog } from "@/components/admin/create-user-dialog"; import Link from "next/link"; export const metadata: Metadata = { title: "User Management" }; @@ -28,7 +29,10 @@ export default async function AdminUsersPage() { return (
-

Users

+
+

Users

+ +
diff --git a/apps/web/app/api/v1/admin/invites/[id]/route.ts b/apps/web/app/api/v1/admin/invites/[id]/route.ts new file mode 100644 index 0000000..b42ca8e --- /dev/null +++ b/apps/web/app/api/v1/admin/invites/[id]/route.ts @@ -0,0 +1,30 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/api-auth"; +import { db, invites, auditLogs, eq } from "@epicure/db"; +import { randomUUID } from "crypto"; + +interface RouteContext { + params: Promise<{ id: string }>; +} + +export async function DELETE(_req: NextRequest, { params }: RouteContext) { + const { session, response } = await requireAdmin(); + if (response) return response; + + const { id } = await params; + const [deleted] = await db.delete(invites).where(eq(invites.id, id)).returning(); + if (!deleted) { + return NextResponse.json({ error: "Invite not found" }, { status: 404 }); + } + + await db.insert(auditLogs).values({ + id: randomUUID(), + userId: session!.user.id, + action: "admin.invite.revoke", + targetType: "invite", + targetId: id, + createdAt: new Date(), + }); + + return NextResponse.json({ ok: true }); +} diff --git a/apps/web/app/api/v1/admin/invites/route.ts b/apps/web/app/api/v1/admin/invites/route.ts new file mode 100644 index 0000000..90c40bd --- /dev/null +++ b/apps/web/app/api/v1/admin/invites/route.ts @@ -0,0 +1,57 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/api-auth"; +import { db, auditLogs } from "@epicure/db"; +import { createInvite, listInvites } from "@/lib/invites"; +import { randomUUID } from "crypto"; + +const VALID_ROLES = ["user", "moderator", "admin"] as const; +const VALID_TIERS = ["free", "pro"] as const; + +export async function GET() { + const { response } = await requireAdmin(); + if (response) return response; + + const invites = await listInvites(); + return NextResponse.json({ invites }); +} + +export async function POST(req: NextRequest) { + const { session, response } = await requireAdmin(); + if (response) return response; + + const body = (await req.json()) as { + email?: string; + role?: string; + tier?: string; + expiresInDays?: number; + }; + + const role = body.role ?? "user"; + const tier = body.tier ?? "free"; + if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + if (!VALID_TIERS.includes(tier as (typeof VALID_TIERS)[number])) { + return NextResponse.json({ error: "Invalid tier" }, { status: 400 }); + } + + const invite = await createInvite({ + createdById: session!.user.id, + email: body.email, + role: role as (typeof VALID_ROLES)[number], + tier: tier as (typeof VALID_TIERS)[number], + expiresInDays: body.expiresInDays ?? 7, + }); + + await db.insert(auditLogs).values({ + id: randomUUID(), + userId: session!.user.id, + action: "admin.invite.create", + targetType: "invite", + targetId: invite!.id, + metadata: JSON.stringify({ email: body.email, role, tier }), + createdAt: new Date(), + }); + + return NextResponse.json({ invite }); +} diff --git a/apps/web/app/api/v1/admin/settings/route.ts b/apps/web/app/api/v1/admin/settings/route.ts index 772d4de..92e36d3 100644 --- a/apps/web/app/api/v1/admin/settings/route.ts +++ b/apps/web/app/api/v1/admin/settings/route.ts @@ -13,6 +13,7 @@ const ALLOWED_KEYS: SiteSettingKey[] = [ "OLLAMA_BASE_URL", "NEXT_PUBLIC_VAPID_PUBLIC_KEY", "VAPID_PRIVATE_KEY", + "SIGNUPS_DISABLED", ]; async function requireAdmin() { diff --git a/apps/web/app/api/v1/admin/users/route.ts b/apps/web/app/api/v1/admin/users/route.ts new file mode 100644 index 0000000..4e054d7 --- /dev/null +++ b/apps/web/app/api/v1/admin/users/route.ts @@ -0,0 +1,100 @@ +import { NextRequest, NextResponse } from "next/server"; +import { requireAdmin } from "@/lib/api-auth"; +import { auth } from "@/lib/auth/server"; +import { db, users, auditLogs, eq } from "@epicure/db"; +import { createInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites"; +import { randomBytes, randomUUID } from "crypto"; +import { APIError } from "better-auth"; + +const VALID_ROLES = ["user", "moderator", "admin"] as const; +const VALID_TIERS = ["free", "pro"] as const; + +export async function POST(req: NextRequest) { + const { session, response } = await requireAdmin(); + if (response) return response; + + const body = (await req.json()) as { + email?: string; + name?: string; + role?: string; + tier?: string; + }; + + const email = body.email?.trim().toLowerCase(); + const name = body.name?.trim(); + const role = body.role ?? "user"; + const tier = body.tier ?? "free"; + + if (!email || !name) { + return NextResponse.json({ error: "email and name are required" }, { status: 400 }); + } + if (!VALID_ROLES.includes(role as (typeof VALID_ROLES)[number])) { + return NextResponse.json({ error: "Invalid role" }, { status: 400 }); + } + if (!VALID_TIERS.includes(tier as (typeof VALID_TIERS)[number])) { + return NextResponse.json({ error: "Invalid tier" }, { status: 400 }); + } + + const [existing] = await db.select({ id: users.id }).from(users).where(eq(users.email, email)); + if (existing) { + return NextResponse.json({ error: "A user with this email already exists" }, { status: 409 }); + } + + // Route creation through the same invite gate the public signup flow uses, + // so it works identically whether signups are currently open or closed — + // and so role/tier assignment goes through the one audited code path. + const invite = await createInvite({ + createdById: session!.user.id, + email, + role: role as (typeof VALID_ROLES)[number], + tier: tier as (typeof VALID_TIERS)[number], + expiresInDays: 1, + }); + + const temporaryPassword = randomBytes(24).toString("base64url"); + + try { + await auth.api.signUpEmail({ + body: { email, name, password: temporaryPassword }, + headers: new Headers({ cookie: `${INVITE_COOKIE}=${invite!.token}` }), + }); + } catch (err) { + if (err instanceof APIError) { + return NextResponse.json({ error: err.message }, { status: err.statusCode ?? 400 }); + } + throw err; + } + + const [created] = await db + .update(users) + .set({ emailVerified: true }) + .where(eq(users.email, email)) + .returning(); + + if (!created) { + return NextResponse.json({ error: "User creation failed" }, { status: 500 }); + } + + // The invite gate only consumes on the databaseHooks "after" path when a + // cookie is present on a real request; belt-and-suspenders it here too. + await consumeInvite(invite!.id, created.id); + + // Let the new user set their own password instead of the admin knowing it. + await auth.api.requestPasswordReset({ + body: { email, redirectTo: "/reset-password" }, + }); + + await db.insert(auditLogs).values({ + id: randomUUID(), + userId: session!.user.id, + action: "admin.user.create", + targetType: "user", + targetId: created.id, + metadata: JSON.stringify({ email, role, tier }), + createdAt: new Date(), + }); + + return NextResponse.json({ + user: { id: created.id, email: created.email, name: created.name, role: created.role, tier: created.tier }, + }); +} diff --git a/apps/web/app/api/v1/invites/[token]/route.ts b/apps/web/app/api/v1/invites/[token]/route.ts new file mode 100644 index 0000000..b9bd9de --- /dev/null +++ b/apps/web/app/api/v1/invites/[token]/route.ts @@ -0,0 +1,15 @@ +import { NextRequest, NextResponse } from "next/server"; +import { findValidInvite } from "@/lib/invites"; + +interface RouteContext { + params: Promise<{ token: string }>; +} + +export async function GET(_req: NextRequest, { params }: RouteContext) { + const { token } = await params; + const invite = await findValidInvite(token); + if (!invite) { + return NextResponse.json({ valid: false }); + } + return NextResponse.json({ valid: true, email: invite.email }); +} diff --git a/apps/web/components/admin/create-user-dialog.tsx b/apps/web/components/admin/create-user-dialog.tsx new file mode 100644 index 0000000..7eb2dd1 --- /dev/null +++ b/apps/web/components/admin/create-user-dialog.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useState } from "react"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Plus } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +export function CreateUserDialog() { + const router = useRouter(); + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [name, setName] = useState(""); + const [role, setRole] = useState<"user" | "moderator" | "admin">("user"); + const [tier, setTier] = useState<"free" | "pro">("free"); + const [saving, setSaving] = useState(false); + + async function handleCreate() { + if (!email.trim() || !name.trim()) { + toast.error("Email and name are required"); + return; + } + setSaving(true); + try { + const res = await fetch("/api/v1/admin/users", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim(), name: name.trim(), role, tier }), + }); + if (!res.ok) { + const data = await res.json().catch(() => ({})); + throw new Error((data as { error?: string }).error ?? "Failed to create user"); + } + toast.success("User created — they'll receive an email to set their password"); + setOpen(false); + setEmail(""); + setName(""); + setRole("user"); + setTier("free"); + router.refresh(); + } catch (err) { + toast.error(err instanceof Error ? err.message : "Failed to create user"); + } finally { + setSaving(false); + } + } + + return ( + <> + + + + Create user +
+
+ + setEmail(e.target.value)} /> +
+
+ + setName(e.target.value)} /> +
+
+
+ + +
+
+ + +
+
+ +
+
+
+ + ); +} diff --git a/apps/web/components/admin/invites-manager.tsx b/apps/web/components/admin/invites-manager.tsx new file mode 100644 index 0000000..d18914f --- /dev/null +++ b/apps/web/components/admin/invites-manager.tsx @@ -0,0 +1,157 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { Copy, Trash2 } from "lucide-react"; + +type Invite = { + id: string; + token: string; + email: string | null; + role: "user" | "moderator" | "admin"; + tier: "free" | "pro"; + createdAt: string; + expiresAt: string | null; +}; + +export function InvitesManager({ invites, appUrl }: { invites: Invite[]; appUrl: string }) { + const router = useRouter(); + const [email, setEmail] = useState(""); + const [role, setRole] = useState<"user" | "moderator" | "admin">("user"); + const [tier, setTier] = useState<"free" | "pro">("free"); + const [creating, setCreating] = useState(false); + + async function handleCreate() { + setCreating(true); + try { + const res = await fetch("/api/v1/admin/invites", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email || undefined, role, tier }), + }); + if (!res.ok) throw new Error("Failed to create invite"); + setEmail(""); + toast.success("Invite created"); + router.refresh(); + } catch { + toast.error("Failed to create invite"); + } finally { + setCreating(false); + } + } + + async function handleRevoke(id: string) { + if (!confirm("Revoke this invite? The link will stop working.")) return; + try { + const res = await fetch(`/api/v1/admin/invites/${id}`, { method: "DELETE" }); + if (!res.ok) throw new Error("Failed to revoke"); + toast.success("Invite revoked"); + router.refresh(); + } catch { + toast.error("Failed to revoke invite"); + } + } + + function copyLink(token: string) { + const url = `${appUrl}/signup?invite=${token}`; + void navigator.clipboard.writeText(url); + toast.success("Link copied"); + } + + return ( +
+
+

New invite

+
+
+ + setEmail(e.target.value)} + /> +
+
+ + +
+
+ + +
+
+ +
+ +
+
+ + + + + + + + + + + {invites.length === 0 && ( + + + + )} + {invites.map((invite) => ( + + + + + + + + ))} + +
EmailRoleTierExpires
+ No active invites. +
{invite.email ?? Anyone}{invite.role}{invite.tier} + {invite.expiresAt ? new Date(invite.expiresAt).toLocaleDateString() : "Never"} + +
+ + +
+
+
+
+ ); +} diff --git a/apps/web/components/admin/signups-toggle.tsx b/apps/web/components/admin/signups-toggle.tsx new file mode 100644 index 0000000..d76f5a6 --- /dev/null +++ b/apps/web/components/admin/signups-toggle.tsx @@ -0,0 +1,55 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Label } from "@/components/ui/label"; +import { Switch } from "@/components/ui/switch"; + +export function SignupsToggle({ initialDisabled }: { initialDisabled: boolean }) { + const [disabled, setDisabled] = useState(initialDisabled); + const [saving, setSaving] = useState(false); + + async function handleChange(checked: boolean) { + setSaving(true); + const previous = disabled; + setDisabled(checked); + try { + const res = await fetch("/api/v1/admin/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ SIGNUPS_DISABLED: checked ? "true" : null }), + }); + if (!res.ok) throw new Error("Save failed"); + toast.success(checked ? "Signups disabled" : "Signups enabled"); + } catch { + setDisabled(previous); + toast.error("Failed to update"); + } finally { + setSaving(false); + } + } + + return ( +
+
+
+

Signups

+

+ When disabled, only people with a valid invite link can create an account. +

+
+
+ + { void handleChange(checked); }} + /> +
+
+
+ ); +} diff --git a/apps/web/lib/auth/server.ts b/apps/web/lib/auth/server.ts index b7d1e54..1989d87 100644 --- a/apps/web/lib/auth/server.ts +++ b/apps/web/lib/auth/server.ts @@ -3,6 +3,8 @@ import { genericOAuth } from "better-auth/plugins"; import { drizzleAdapter } from "better-auth/adapters/drizzle"; import { db, users, sessions, accounts, verifications, eq, count } from "@epicure/db"; import { sendEmail, verifyEmailHtml, resetPasswordHtml, welcomeHtml } from "@/lib/email"; +import { isSignupsDisabled } from "@/lib/site-settings"; +import { findValidInvite, consumeInvite, INVITE_COOKIE } from "@/lib/invites"; export const auth = betterAuth({ trustedOrigins: [process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"], @@ -82,12 +84,28 @@ export const auth = betterAuth({ databaseHooks: { user: { create: { - after: async (user) => { + before: async (user, context) => { + if (!(await isSignupsDisabled())) return; + + const token = context?.getCookie(INVITE_COOKIE); + const invite = token ? await findValidInvite(token, user.email) : null; + if (!invite) return false; + + return { data: { ...user, role: invite.role, tier: invite.tier } }; + }, + after: async (user, context) => { // First registered user becomes admin const result = await db.select({ total: count() }).from(users); if ((result[0]?.total ?? 0) === 1) { await db.update(users).set({ role: "admin" }).where(eq(users.id, user.id)); } + + // Consume the invite that gated this signup, if any (regardless of + // whether signups have since been re-enabled/disabled). + const token = context?.getCookie(INVITE_COOKIE); + const invite = token ? await findValidInvite(token, user.email) : null; + if (invite) await consumeInvite(invite.id, user.id); + // Welcome email (fire and forget) sendEmail({ to: user.email, diff --git a/apps/web/lib/invites.ts b/apps/web/lib/invites.ts new file mode 100644 index 0000000..3bd9bcb --- /dev/null +++ b/apps/web/lib/invites.ts @@ -0,0 +1,53 @@ +import { db, invites, eq, isNull } from "@epicure/db"; +import { randomUUID, randomBytes } from "crypto"; + +export const INVITE_COOKIE = "epicure_invite"; + +export function generateInviteToken(): string { + return randomBytes(24).toString("base64url"); +} + +export async function findValidInvite(token: string, email?: string | null) { + const [invite] = await db.select().from(invites).where(eq(invites.token, token)); + if (!invite) return null; + if (invite.usedAt) return null; + if (invite.expiresAt && invite.expiresAt < new Date()) return null; + if (invite.email && email && invite.email.toLowerCase() !== email.toLowerCase()) return null; + return invite; +} + +export async function consumeInvite(inviteId: string, userId: string): Promise { + await db + .update(invites) + .set({ usedAt: new Date(), usedById: userId }) + .where(eq(invites.id, inviteId)); +} + +export async function createInvite(opts: { + createdById: string; + email?: string | null; + role?: "user" | "moderator" | "admin"; + tier?: "free" | "pro"; + expiresInDays?: number | null; +}) { + const [invite] = await db + .insert(invites) + .values({ + id: randomUUID(), + token: generateInviteToken(), + email: opts.email || null, + role: opts.role ?? "user", + tier: opts.tier ?? "free", + createdById: opts.createdById, + expiresAt: opts.expiresInDays ? new Date(Date.now() + opts.expiresInDays * 86400_000) : null, + }) + .returning(); + return invite; +} + +export async function listInvites() { + return db.query.invites.findMany({ + where: isNull(invites.usedAt), + orderBy: (i, { desc }) => [desc(i.createdAt)], + }); +} diff --git a/apps/web/lib/site-settings.ts b/apps/web/lib/site-settings.ts index 0c32217..ee0205a 100644 --- a/apps/web/lib/site-settings.ts +++ b/apps/web/lib/site-settings.ts @@ -8,7 +8,8 @@ export type SiteSettingKey = | "OPENROUTER_DEFAULT_MODEL" | "OLLAMA_BASE_URL" | "NEXT_PUBLIC_VAPID_PUBLIC_KEY" - | "VAPID_PRIVATE_KEY"; + | "VAPID_PRIVATE_KEY" + | "SIGNUPS_DISABLED"; const SECRET_KEYS: SiteSettingKey[] = [ "OPENAI_API_KEY", @@ -69,6 +70,10 @@ export async function getAllSiteSettings(): Promise { + return (await getSiteSetting("SIGNUPS_DISABLED")) === "true"; +} + export async function setSiteSetting( key: SiteSettingKey, value: string | null, diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts index 7f6b05f..f676179 100644 --- a/apps/web/proxy.ts +++ b/apps/web/proxy.ts @@ -1,7 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; import { getSessionCookie } from "better-auth/cookies"; -const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks"]; +const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/"]; const ADMIN_PATHS = ["/admin"]; export async function proxy(request: NextRequest) { diff --git a/packages/db/src/migrations/0015_aromatic_lester.sql b/packages/db/src/migrations/0015_aromatic_lester.sql new file mode 100644 index 0000000..ce624e8 --- /dev/null +++ b/packages/db/src/migrations/0015_aromatic_lester.sql @@ -0,0 +1,16 @@ +CREATE TABLE "invites" ( + "id" text PRIMARY KEY NOT NULL, + "token" text NOT NULL, + "email" text, + "role" "user_role" DEFAULT 'user' NOT NULL, + "tier" "tier" DEFAULT 'free' NOT NULL, + "created_by_id" text NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "expires_at" timestamp, + "used_at" timestamp, + "used_by_id" text, + CONSTRAINT "invites_token_uniq" UNIQUE("token") +); +--> statement-breakpoint +ALTER TABLE "invites" ADD CONSTRAINT "invites_created_by_id_users_id_fk" FOREIGN KEY ("created_by_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "invites" ADD CONSTRAINT "invites_used_by_id_users_id_fk" FOREIGN KEY ("used_by_id") REFERENCES "public"."users"("id") ON DELETE set null ON UPDATE no action; \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0015_snapshot.json b/packages/db/src/migrations/meta/0015_snapshot.json new file mode 100644 index 0000000..0c6d008 --- /dev/null +++ b/packages/db/src/migrations/meta/0015_snapshot.json @@ -0,0 +1,3625 @@ +{ + "id": "0f81d49b-ccda-4bec-8017-45951e8e7207", + "prevId": "81d04db7-c9ad-4aaf-92bc-eb0c52e139c8", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_bio": { + "name": "private_bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_pref": { + "name": "unit_pref", + "type": "unit_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_stripe_customer_id_unique": { + "name": "users_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_dietary_tags_gin": { + "name": "recipes_dietary_tags_gin", + "columns": [ + { + "expression": "dietary_tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_members_user_idx": { + "name": "collection_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_idx": { + "name": "collections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cooking_history_user_idx": { + "name": "cooking_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorites_user_idx": { + "name": "favorites_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ratings_user_idx": { + "name": "ratings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_members": { + "name": "meal_plan_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meal_plan_members_plan_idx": { + "name": "meal_plan_members_plan_idx", + "columns": [ + { + "expression": "meal_plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meal_plan_members_user_idx": { + "name": "meal_plan_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meal_plan_members_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_members_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_members", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_members_user_id_users_id_fk": { + "name": "meal_plan_members_user_id_users_id_fk", + "tableFrom": "meal_plan_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_members": { + "name": "shopping_list_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shopping_list_members_list_idx": { + "name": "shopping_list_members_list_idx", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shopping_list_members_user_idx": { + "name": "shopping_list_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shopping_list_members_list_id_shopping_lists_id_fk": { + "name": "shopping_list_members_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_members", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_members_user_id_users_id_fk": { + "name": "shopping_list_members_user_id_users_id_fk", + "tableFrom": "shopping_list_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invites": { + "name": "invites", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "created_by_id": { + "name": "created_by_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "used_at": { + "name": "used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "used_by_id": { + "name": "used_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "invites_created_by_id_users_id_fk": { + "name": "invites_created_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "created_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invites_used_by_id_users_id_fk": { + "name": "invites_used_by_id_users_id_fk", + "tableFrom": "invites", + "tableTo": "users", + "columnsFrom": [ + "used_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invites_token_uniq": { + "name": "invites_token_uniq", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 9c9872b..f4f9eea 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -106,6 +106,13 @@ "when": 1782984288632, "tag": "0014_late_marvex", "breakpoints": true + }, + { + "idx": 15, + "version": "7", + "when": 1783105214683, + "tag": "0015_aromatic_lester", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/tiers.ts b/packages/db/src/schema/tiers.ts index 6f76ddd..05f264c 100644 --- a/packages/db/src/schema/tiers.ts +++ b/packages/db/src/schema/tiers.ts @@ -8,7 +8,7 @@ import { unique, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; -import { users, tierEnum } from "./users"; +import { users, tierEnum, userRoleEnum } from "./users"; export const tierDefinitions = pgTable("tier_definitions", { tier: tierEnum("tier").primaryKey(), @@ -50,6 +50,26 @@ export const siteSettings = pgTable("site_settings", { updatedById: text("updated_by_id").references(() => users.id, { onDelete: "set null" }), }); +export const invites = pgTable("invites", { + id: text("id").primaryKey(), + token: text("token").notNull(), + email: text("email"), + role: userRoleEnum("role").notNull().default("user"), + tier: tierEnum("tier").notNull().default("free"), + createdById: text("created_by_id").notNull().references(() => users.id, { onDelete: "cascade" }), + createdAt: timestamp("created_at").notNull().defaultNow(), + expiresAt: timestamp("expires_at"), + usedAt: timestamp("used_at"), + usedById: text("used_by_id").references(() => users.id, { onDelete: "set null" }), +}, (t) => [ + unique("invites_token_uniq").on(t.token), +]); + export const userUsageRelations = relations(userUsage, ({ one }) => ({ user: one(users, { fields: [userUsage.userId], references: [users.id] }), })); + +export const invitesRelations = relations(invites, ({ one }) => ({ + createdBy: one(users, { fields: [invites.createdById], references: [users.id] }), + usedBy: one(users, { fields: [invites.usedById], references: [users.id] }), +}));