diff --git a/CHANGELOG.md b/CHANGELOG.md
index 33a060c..80d63ad 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,6 +2,11 @@
All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together.
+## 0.72.0 — 2026-07-23 09:30
+
+### Added
+- Developer access (webhooks + API keys) is now self-serve for any paid-tier user, free of charge — enable it yourself in Settings instead of waiting on an admin. Free tier still needs an admin grant. BYOK is now a fully separate permission, admin-only, unaffected by the self-serve change.
+
## 0.71.0 — 2026-07-22 10:00
### Added
diff --git a/FEATURE_AUDIT.md b/FEATURE_AUDIT.md
index bb54cd4..9aa7ff4 100644
--- a/FEATURE_AUDIT.md
+++ b/FEATURE_AUDIT.md
@@ -105,7 +105,7 @@ Status legend: **Exists** (fully working) · **Partial** (works but with a real
| **Self-serve billing portal** | **Missing** | No Stripe customer portal; tier changes today only happen via direct admin edit at `/admin/users/[id]` | — |
| Admin dashboard | Exists | 13 sections: overview, insights/analytics, users, invites, recipe moderation, reports, support, tiers, webhooks, audit logs, storage, AI config, site settings, changelog | `apps/web/app/admin/**` |
| Feature flags (per-tier) vs feature prefs (per-user cosmetic) | Exists, two distinct systems | Flags gate 3 AI capabilities by tier; prefs let users hide 7 nav sections, no billing implication | `apps/web/lib/{feature-flags,feature-prefs}.ts` |
-| **Developer permission** (new, 2026-07-22) | Exists | A fourth, orthogonal access dimension — `users.isDeveloper`, admin-toggled in `admin/users/[id]`, gates user webhooks + self-serve API keys + BYOK. Previously all three had zero gating (any logged-in user, any tier). Existing users with a webhook/API key/BYOK key were grandfathered in by the migration. Deliberately a single `hasDeveloperAccess()` function so a future subscription-tier auto-grant is a one-line change, not a redesign. | `apps/web/lib/permissions.ts`, `apps/web/lib/api-auth.ts` (`requireDeveloper`) |
+| **Developer permission** (2026-07-22, split 2026-07-23) | Exists | Two separate, orthogonal permissions — not one. `users.isDeveloper` gates webhooks + self-serve API keys: admin-toggled always, *and* self-serve toggleable by the user themselves once on a paid tier (no added fee) via `PATCH /api/v1/users/me/developer-access`; free-tier users still need an admin grant. `users.isByokEnabled` gates BYOK separately, admin-only, no self-serve — routing real AI spend through Epicure on the user's own key warrants a manual check-in. Previously all three (webhooks/API keys/BYOK) shared one flag with zero self-serve path. Existing users with a webhook/API key were already grandfathered for `isDeveloper`; a second migration grandfathered existing BYOK users into `isByokEnabled` specifically. | `apps/web/lib/permissions.ts` (`hasDeveloperAccess`, `hasByokAccess`, `canSelfServeDeveloperAccess`), `apps/web/lib/api-auth.ts` (`requireDeveloper`, `requireByok`) |
| User webhooks (personal automation) | Exists | 7 events, Zapier-style, requires developer access | `apps/web/lib/webhooks.ts` |
| Admin ops webhooks (site-wide) | Exists | 3 events (signup, ticket, report) — admin-only, unrelated to developer access | `apps/web/lib/admin-webhooks.ts` |
| Support tickets ↔ Gitea two-way sync | Exists, verified real | Outbound (create/comment/close mirror to Gitea issue) + inbound (signed webhook receiver, dedup, loop-safe) | `apps/web/lib/gitea.ts`, `apps/web/app/api/webhooks/gitea/route.ts` |
diff --git a/apps/web/app/(app)/settings/ai/page.tsx b/apps/web/app/(app)/settings/ai/page.tsx
index 440fe08..e69e4d1 100644
--- a/apps/web/app/(app)/settings/ai/page.tsx
+++ b/apps/web/app/(app)/settings/ai/page.tsx
@@ -28,7 +28,7 @@ export default async function AiSettingsPage() {
}),
// Tier comes from the DB, not the (up to 5-minute-stale) session cookie
// cache, so a just-changed tier's limits show up immediately here.
- db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true, isDeveloper: true } }),
+ db.query.users.findFirst({ where: eq(users.id, session.user.id), columns: { tier: true, isByokEnabled: true } }),
]);
const [tierDef, usage, recipeCount, storageUsedMb] = await Promise.all([
@@ -81,10 +81,10 @@ export default async function AiSettingsPage() {
{m.settings.byok.description}
- {dbUser?.isDeveloper ? (
+ {dbUser?.isByokEnabled ? (
k.provider)} />
) : (
-
+
)}
diff --git a/apps/web/app/(app)/settings/api-keys/page.tsx b/apps/web/app/(app)/settings/api-keys/page.tsx
index 9028c26..f8ddd02 100644
--- a/apps/web/app/(app)/settings/api-keys/page.tsx
+++ b/apps/web/app/(app)/settings/api-keys/page.tsx
@@ -6,6 +6,9 @@ import { auth } from "@/lib/auth/server";
import { db, apiKeys, users, eq } from "@epicure/db";
import { ApiKeysManager } from "@/components/settings/api-keys-manager";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
+import { DeveloperAccessToggle } from "@/components/settings/developer-access-toggle";
+import { DisableDeveloperAccessButton } from "@/components/settings/disable-developer-access-button";
+import { canSelfServeDeveloperAccess } from "@/lib/permissions";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -15,7 +18,7 @@ export default async function ApiKeysPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
- const dbUser = (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
+ const dbUser = (await db.select({ isDeveloper: users.isDeveloper, tier: users.tier }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
const keys = dbUser?.isDeveloper
? await db
@@ -47,17 +50,25 @@ export default async function ApiKeysPage() {
{m.settings.apiKeysPage.docsLink}
- {!dbUser?.isDeveloper && }
+ {!dbUser?.isDeveloper && dbUser && canSelfServeDeveloperAccess(dbUser) && (
+
+ )}
+ {!dbUser?.isDeveloper && (!dbUser || !canSelfServeDeveloperAccess(dbUser)) && (
+
+ )}
{dbUser?.isDeveloper && (
- ({
- id: k.id,
- name: k.name,
- scope: k.scope,
- lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
- createdAt: k.createdAt.toISOString(),
- }))}
- />
+ <>
+ ({
+ id: k.id,
+ name: k.name,
+ scope: k.scope,
+ lastUsedAt: k.lastUsedAt ? k.lastUsedAt.toISOString() : null,
+ createdAt: k.createdAt.toISOString(),
+ }))}
+ />
+
+ >
)}
diff --git a/apps/web/app/(app)/settings/layout.tsx b/apps/web/app/(app)/settings/layout.tsx
index 9908829..c3db5dc 100644
--- a/apps/web/app/(app)/settings/layout.tsx
+++ b/apps/web/app/(app)/settings/layout.tsx
@@ -3,14 +3,16 @@ import { auth } from "@/lib/auth/server";
import { db, users, eq } from "@epicure/db";
import { SettingsSidebar } from "@/components/settings/settings-sidebar";
import { getMessages } from "@/lib/i18n/server";
+import { canSelfServeDeveloperAccess } from "@/lib/permissions";
export default async function SettingsLayout({ children }: { children: React.ReactNode }) {
const session = await auth.api.getSession({ headers: await headers() });
const m = getMessages((session?.user as { locale?: string })?.locale);
const dbUser = session
- ? (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0]
+ ? (await db.select({ isDeveloper: users.isDeveloper, tier: users.tier }).from(users).where(eq(users.id, session.user.id)).limit(1))[0]
: undefined;
+ const showDeveloperNav = !!dbUser && (dbUser.isDeveloper || canSelfServeDeveloperAccess(dbUser));
return (
@@ -19,7 +21,7 @@ export default async function SettingsLayout({ children }: { children: React.Rea
{m.settings.subtitle}
-
+
{children}
diff --git a/apps/web/app/(app)/settings/webhooks/page.tsx b/apps/web/app/(app)/settings/webhooks/page.tsx
index f43f57a..e91e39d 100644
--- a/apps/web/app/(app)/settings/webhooks/page.tsx
+++ b/apps/web/app/(app)/settings/webhooks/page.tsx
@@ -4,6 +4,9 @@ import { auth } from "@/lib/auth/server";
import { db, webhooks, users, eq } from "@epicure/db";
import { WebhooksManager } from "@/components/settings/webhooks-manager";
import { DeveloperLockedNotice } from "@/components/settings/developer-locked-notice";
+import { DeveloperAccessToggle } from "@/components/settings/developer-access-toggle";
+import { DisableDeveloperAccessButton } from "@/components/settings/disable-developer-access-button";
+import { canSelfServeDeveloperAccess } from "@/lib/permissions";
import { getMessages } from "@/lib/i18n/server";
export const metadata: Metadata = {};
@@ -13,7 +16,7 @@ export default async function WebhooksPage() {
if (!session) return null;
const m = getMessages((session.user as { locale?: string }).locale);
- const dbUser = (await db.select({ isDeveloper: users.isDeveloper }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
+ const dbUser = (await db.select({ isDeveloper: users.isDeveloper, tier: users.tier }).from(users).where(eq(users.id, session.user.id)).limit(1))[0];
const rows = dbUser?.isDeveloper
? await db
@@ -37,17 +40,25 @@ export default async function WebhooksPage() {
{m.settings.webhooksPage.description}
- {!dbUser?.isDeveloper && }
+ {!dbUser?.isDeveloper && dbUser && canSelfServeDeveloperAccess(dbUser) && (
+
+ )}
+ {!dbUser?.isDeveloper && (!dbUser || !canSelfServeDeveloperAccess(dbUser)) && (
+
+ )}
{dbUser?.isDeveloper && (
- ({
- id: w.id,
- url: w.url,
- events: w.events,
- active: w.active,
- createdAt: w.createdAt.toISOString(),
- }))}
- />
+ <>
+ ({
+ id: w.id,
+ url: w.url,
+ events: w.events,
+ active: w.active,
+ createdAt: w.createdAt.toISOString(),
+ }))}
+ />
+
+ >
)}
diff --git a/apps/web/app/admin/users/[id]/page.tsx b/apps/web/app/admin/users/[id]/page.tsx
index 8818e08..18e7a26 100644
--- a/apps/web/app/admin/users/[id]/page.tsx
+++ b/apps/web/app/admin/users/[id]/page.tsx
@@ -102,7 +102,7 @@ export default async function AdminUserDetailPage({ params }: PageProps) {
Edit Role & Tier
-
+
diff --git a/apps/web/app/api/v1/admin/users/[id]/route.ts b/apps/web/app/api/v1/admin/users/[id]/route.ts
index 6f6176b..fb978a6 100644
--- a/apps/web/app/api/v1/admin/users/[id]/route.ts
+++ b/apps/web/app/api/v1/admin/users/[id]/route.ts
@@ -12,8 +12,8 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (response) return response;
const { id } = await params;
- const body = await req.json() as { role?: string; tier?: string; isDeveloper?: boolean };
- const { role, tier, isDeveloper } = body;
+ const body = await req.json() as { role?: string; tier?: string; isDeveloper?: boolean; isByokEnabled?: boolean };
+ const { role, tier, isDeveloper, isByokEnabled } = body;
const validRoles = ["user", "moderator", "admin"] as const;
const validTiers = ["free", "pro", "family"] as const;
@@ -27,19 +27,23 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
if (isDeveloper !== undefined && typeof isDeveloper !== "boolean") {
return NextResponse.json({ error: "Invalid isDeveloper" }, { status: 400 });
}
+ if (isByokEnabled !== undefined && typeof isByokEnabled !== "boolean") {
+ return NextResponse.json({ error: "Invalid isByokEnabled" }, { status: 400 });
+ }
- const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; isDeveloper: boolean; updatedAt: Date }> = {
+ const updateData: Partial<{ role: "user" | "moderator" | "admin"; tier: "free" | "pro" | "family"; isDeveloper: boolean; isByokEnabled: boolean; updatedAt: Date }> = {
updatedAt: new Date(),
};
if (role) updateData.role = role as "user" | "moderator" | "admin";
if (tier) updateData.tier = tier as "free" | "pro" | "family";
if (isDeveloper !== undefined) updateData.isDeveloper = isDeveloper;
+ if (isByokEnabled !== undefined) updateData.isByokEnabled = isByokEnabled;
const [updated] = await db
.update(users)
.set(updateData)
.where(eq(users.id, id))
- .returning({ id: users.id, role: users.role, tier: users.tier, isDeveloper: users.isDeveloper });
+ .returning({ id: users.id, role: users.role, tier: users.tier, isDeveloper: users.isDeveloper, isByokEnabled: users.isByokEnabled });
if (!updated) {
return NextResponse.json({ error: "User not found" }, { status: 404 });
@@ -52,7 +56,7 @@ export async function PATCH(req: NextRequest, { params }: RouteContext) {
action: "admin.user.update",
targetType: "user",
targetId: id,
- metadata: JSON.stringify({ role, tier, isDeveloper }),
+ metadata: JSON.stringify({ role, tier, isDeveloper, isByokEnabled }),
createdAt: new Date(),
});
diff --git a/apps/web/app/api/v1/ai-keys/[provider]/route.ts b/apps/web/app/api/v1/ai-keys/[provider]/route.ts
index 30103ca..51c222e 100644
--- a/apps/web/app/api/v1/ai-keys/[provider]/route.ts
+++ b/apps/web/app/api/v1/ai-keys/[provider]/route.ts
@@ -1,11 +1,11 @@
import { NextResponse } from "next/server";
import { db, userAiKeys, eq, and } from "@epicure/db";
-import { requireDeveloper } from "@/lib/api-auth";
+import { requireByok } from "@/lib/api-auth";
type Params = { params: Promise<{ provider: string }> };
export async function DELETE(_req: Request, { params }: Params) {
- const { session, response } = await requireDeveloper();
+ const { session, response } = await requireByok();
if (response) return response;
const { provider } = await params;
diff --git a/apps/web/app/api/v1/ai-keys/route.ts b/apps/web/app/api/v1/ai-keys/route.ts
index 85f2a01..198dfa8 100644
--- a/apps/web/app/api/v1/ai-keys/route.ts
+++ b/apps/web/app/api/v1/ai-keys/route.ts
@@ -1,7 +1,7 @@
import { NextResponse } from "next/server";
import { z } from "zod";
import { db, userAiKeys, eq, and } from "@epicure/db";
-import { requireDeveloper } from "@/lib/api-auth";
+import { requireByok } from "@/lib/api-auth";
import { encrypt } from "@/lib/encrypt";
import { applyRateLimit } from "@/lib/rate-limit";
@@ -13,7 +13,7 @@ const PostSchema = z.object({
});
export async function GET() {
- const { session, response } = await requireDeveloper();
+ const { session, response } = await requireByok();
if (response) return response;
const keys = await db.query.userAiKeys.findMany({
@@ -25,7 +25,7 @@ export async function GET() {
}
export async function POST(req: Request) {
- const { session, response } = await requireDeveloper();
+ const { session, response } = await requireByok();
if (response) return response;
const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600);
diff --git a/apps/web/app/api/v1/users/me/developer-access/route.ts b/apps/web/app/api/v1/users/me/developer-access/route.ts
new file mode 100644
index 0000000..367b080
--- /dev/null
+++ b/apps/web/app/api/v1/users/me/developer-access/route.ts
@@ -0,0 +1,35 @@
+import { NextRequest, NextResponse } from "next/server";
+import { z } from "zod";
+import { db, users, eq } from "@epicure/db";
+import { requireSession } from "@/lib/api-auth";
+import { canSelfServeDeveloperAccess } from "@/lib/permissions";
+
+const PatchSchema = z.object({ enabled: z.boolean() });
+
+/** Self-serve on/off for the same isDeveloper flag admin/users/[id] can
+ * also set — open to any paid tier, no added fee. Free tier still needs
+ * an admin grant (requireAdmin's path, unchanged). */
+export async function PATCH(req: NextRequest) {
+ const { session, response } = await requireSession();
+ if (response) return response;
+
+ const parsed = PatchSchema.safeParse(await req.json());
+ if (!parsed.success) {
+ return NextResponse.json({ error: "Validation error", issues: parsed.error.issues }, { status: 400 });
+ }
+
+ // Turning it off is always allowed, regardless of tier or who granted
+ // it — only turning it ON self-serve requires a paid tier.
+ if (parsed.data.enabled) {
+ // Don't trust session.user.tier — up to 5-minute-stale cookieCache,
+ // same reasoning as every other fresh-role/tier re-query in this codebase.
+ const [dbUser] = await db.select({ tier: users.tier }).from(users).where(eq(users.id, session!.user.id)).limit(1);
+ if (!dbUser || !canSelfServeDeveloperAccess(dbUser)) {
+ return NextResponse.json({ error: "Upgrade to a paid plan to enable developer access yourself, or ask an admin" }, { status: 403 });
+ }
+ }
+
+ await db.update(users).set({ isDeveloper: parsed.data.enabled, updatedAt: new Date() }).where(eq(users.id, session!.user.id));
+
+ return NextResponse.json({ ok: true, isDeveloper: parsed.data.enabled });
+}
diff --git a/apps/web/components/admin/user-editor.tsx b/apps/web/components/admin/user-editor.tsx
index bb19ae9..7a995f4 100644
--- a/apps/web/components/admin/user-editor.tsx
+++ b/apps/web/components/admin/user-editor.tsx
@@ -18,12 +18,14 @@ interface UserEditorProps {
currentRole: "user" | "moderator" | "admin";
currentTier: "free" | "pro" | "family";
currentIsDeveloper: boolean;
+ currentIsByokEnabled: boolean;
}
-export function UserEditor({ userId, currentRole, currentTier, currentIsDeveloper }: UserEditorProps) {
+export function UserEditor({ userId, currentRole, currentTier, currentIsDeveloper, currentIsByokEnabled }: UserEditorProps) {
const [role, setRole] = useState<"user" | "moderator" | "admin">(currentRole);
const [tier, setTier] = useState<"free" | "pro" | "family">(currentTier);
const [isDeveloper, setIsDeveloper] = useState(currentIsDeveloper);
+ const [isByokEnabled, setIsByokEnabled] = useState(currentIsByokEnabled);
const [saving, setSaving] = useState(false);
async function handleSave() {
@@ -32,7 +34,7 @@ export function UserEditor({ userId, currentRole, currentTier, currentIsDevelope
const res = await fetch(`/api/v1/admin/users/${userId}`, {
method: "PATCH",
headers: { "Content-Type": "application/json" },
- body: JSON.stringify({ role, tier, isDeveloper }),
+ body: JSON.stringify({ role, tier, isDeveloper, isByokEnabled }),
});
if (!res.ok) {
const data = await res.json().catch(() => ({}));
@@ -79,10 +81,17 @@ export function UserEditor({ userId, currentRole, currentTier, currentIsDevelope
Developer access
-
Enables webhooks, self-serve API keys, and BYOK AI provider keys.
+
Enables webhooks and self-serve API keys. Users on a paid tier can also self-enable this.
+
+
+
BYOK access
+
Enables bring-your-own AI provider keys. Admin-only, no self-serve.
+
+
+
{saving ? "Saving…" : "Save Changes"}
diff --git a/apps/web/components/settings/developer-access-toggle.tsx b/apps/web/components/settings/developer-access-toggle.tsx
new file mode 100644
index 0000000..702f4e3
--- /dev/null
+++ b/apps/web/components/settings/developer-access-toggle.tsx
@@ -0,0 +1,44 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+import { Code2 } from "lucide-react";
+import { Button } from "@/components/ui/button";
+
+/** Shown instead of the locked notice on the webhooks/API-keys settings
+ * pages when the user is on a paid tier — self-serve, no admin needed, no
+ * added fee. Reloads the page on success since the manager component
+ * needs its initial data fetched server-side. */
+export function DeveloperAccessToggle({ message }: { message: string }) {
+ const router = useRouter();
+ const [enabling, setEnabling] = useState(false);
+
+ async function enable() {
+ setEnabling(true);
+ try {
+ const res = await fetch("/api/v1/users/me/developer-access", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: true }),
+ });
+ if (!res.ok) throw new Error();
+ router.refresh();
+ } catch {
+ toast.error("Couldn't enable developer access");
+ setEnabling(false);
+ }
+ }
+
+ return (
+
+
+
{ void enable(); }}>
+ {enabling ? "Enabling…" : "Enable"}
+
+
+ );
+}
diff --git a/apps/web/components/settings/disable-developer-access-button.tsx b/apps/web/components/settings/disable-developer-access-button.tsx
new file mode 100644
index 0000000..2e1707b
--- /dev/null
+++ b/apps/web/components/settings/disable-developer-access-button.tsx
@@ -0,0 +1,40 @@
+"use client";
+
+import { useState } from "react";
+import { useRouter } from "next/navigation";
+import { toast } from "sonner";
+
+/** Turning developer access off is always self-serve regardless of tier
+ * or who granted it — only turning it back on (for a free-tier user)
+ * requires an admin. */
+export function DisableDeveloperAccessButton() {
+ const router = useRouter();
+ const [disabling, setDisabling] = useState(false);
+
+ async function disable() {
+ setDisabling(true);
+ try {
+ const res = await fetch("/api/v1/users/me/developer-access", {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ enabled: false }),
+ });
+ if (!res.ok) throw new Error();
+ router.refresh();
+ } catch {
+ toast.error("Couldn't disable developer access");
+ setDisabling(false);
+ }
+ }
+
+ return (
+ { void disable(); }}
+ className="text-xs text-muted-foreground hover:text-destructive underline underline-offset-2"
+ >
+ {disabling ? "Disabling…" : "Disable developer access"}
+
+ );
+}
diff --git a/apps/web/components/settings/settings-sidebar.tsx b/apps/web/components/settings/settings-sidebar.tsx
index 745094e..2900fd8 100644
--- a/apps/web/components/settings/settings-sidebar.tsx
+++ b/apps/web/components/settings/settings-sidebar.tsx
@@ -19,11 +19,11 @@ const NAV_ITEMS = [
const DEVELOPER_ONLY_KEYS = new Set(["apiKeys", "webhooks"]);
-export function SettingsSidebar({ isDeveloper }: { isDeveloper: boolean }) {
+export function SettingsSidebar({ showDeveloperNav }: { showDeveloperNav: boolean }) {
const pathname = usePathname();
const t = useTranslations("settings");
- const visibleItems = NAV_ITEMS.filter((item) => isDeveloper || !DEVELOPER_ONLY_KEYS.has(item.key));
+ const visibleItems = NAV_ITEMS.filter((item) => showDeveloperNav || !DEVELOPER_ONLY_KEYS.has(item.key));
return (
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
index 52eb2cf..0c1ee95 100644
--- a/apps/web/lib/api-auth.ts
+++ b/apps/web/lib/api-auth.ts
@@ -4,7 +4,7 @@ import { NextRequest, NextResponse } from "next/server";
import { auth } from "@/lib/auth/server";
import { db, apiKeys, users, eq } from "@epicure/db";
import { applyRateLimit } from "@/lib/rate-limit";
-import { hasDeveloperAccess } from "@/lib/permissions";
+import { hasDeveloperAccess, hasByokAccess } from "@/lib/permissions";
export async function requireSession() {
const session = await auth.api.getSession({ headers: await headers() });
@@ -40,10 +40,10 @@ export async function requireAdmin(opts?: { allowModerator?: boolean }) {
return { session, response: null };
}
-/** Gates webhooks, self-serve API keys, and BYOK routes behind
- * users.isDeveloper — re-queried fresh for the same reason requireAdmin
- * re-queries role (session.user's cookieCache can be up to 5 minutes
- * stale, e.g. right after an admin grants access). */
+/** Gates webhooks and self-serve API keys behind users.isDeveloper —
+ * re-queried fresh for the same reason requireAdmin re-queries role
+ * (session.user's cookieCache can be up to 5 minutes stale, e.g. right
+ * after a grant). */
export async function requireDeveloper() {
const { session, response } = await requireSession();
if (response) return { session: null, response };
@@ -57,7 +57,28 @@ export async function requireDeveloper() {
if (!dbUser || !hasDeveloperAccess(dbUser)) {
return {
session: null,
- response: NextResponse.json({ error: "Developer access required — ask an admin to enable it" }, { status: 403 }),
+ response: NextResponse.json({ error: "Developer access required — ask an admin to enable it, or self-enable it in Settings if you're on a paid plan" }, { status: 403 }),
+ };
+ }
+ return { session, response: null };
+}
+
+/** Gates BYOK AI provider key routes behind users.isByokEnabled — admin-only,
+ * no self-serve path (see lib/permissions.ts's hasByokAccess doc comment). */
+export async function requireByok() {
+ const { session, response } = await requireSession();
+ if (response) return { session: null, response };
+
+ const [dbUser] = await db
+ .select({ isByokEnabled: users.isByokEnabled })
+ .from(users)
+ .where(eq(users.id, session!.user.id))
+ .limit(1);
+
+ if (!dbUser || !hasByokAccess(dbUser)) {
+ return {
+ session: null,
+ response: NextResponse.json({ error: "BYOK access required — ask an admin to enable it" }, { status: 403 }),
};
}
return { session, response: null };
diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts
index 6819d01..e0bf726 100644
--- a/apps/web/lib/changelog.ts
+++ b/apps/web/lib/changelog.ts
@@ -1,5 +1,5 @@
// Mirrors CHANGELOG.md at the repo root — update both together.
-export const APP_VERSION = "0.71.0";
+export const APP_VERSION = "0.72.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.72.0",
+ date: "2026-07-23 09:30",
+ added: [
+ "Developer access (webhooks + API keys) is now self-serve for any paid-tier user, free of charge — enable it yourself in Settings instead of waiting on an admin. Free tier still needs an admin grant. BYOK is now a fully separate permission, admin-only, unaffected by the self-serve change.",
+ ],
+ },
{
version: "0.71.0",
date: "2026-07-22 10:00",
diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts
index bafea8d..e6a1a09 100644
--- a/apps/web/lib/openapi.ts
+++ b/apps/web/lib/openapi.ts
@@ -534,6 +534,7 @@ export function generateOpenApiSpec(): object {
registry.registerPath({ method: "put", path: "/api/v1/users/me/notification-prefs", summary: "Set your notification category preferences", security, request: { body: { content: { "application/json": { schema: UpdateNotificationPrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/feature-prefs", summary: "Get your feature visibility preferences", description: "Features with no saved row default to visible.", security, responses: { 200: { description: "Preferences", content: { "application/json": { schema: z.object({ data: FeaturePrefsRef }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/feature-prefs", summary: "Set your feature visibility preferences", description: "Purely cosmetic (hides nav items) — does not restrict access to the underlying pages.", security, request: { body: { content: { "application/json": { schema: UpdateFeaturePrefsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
+ registry.registerPath({ method: "patch", path: "/api/v1/users/me/developer-access", summary: "Self-serve enable or disable developer access", description: "Enabling requires a paid tier (free at no extra charge) — free-tier users need an admin grant instead. Disabling is always allowed, regardless of tier or who originally granted it. Only covers webhooks/API keys — BYOK is a separate, admin-only permission.", security, request: { body: { content: { "application/json": { schema: z.object({ enabled: z.boolean() }) } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: z.object({ ok: z.boolean(), isDeveloper: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Free tier — upgrade or ask an admin", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-diary", summary: "Get a day's cooked-recipe nutrition totals vs. your goals, or a multi-day trend", description: "Pass `range` (7, 30, or 90) instead of `date` for a daily calorie/macro trend over that many days — days with nothing cooked appear with zero totals.", security, request: { query: z.object({ date: z.string().optional().describe("ISO date YYYY-MM-DD, defaults to today (UTC). Ignored if range is set."), range: z.enum(["7", "30", "90"]).optional().describe("Switches to trend mode: returns { range, days: [{date, calories, proteinG, carbsG, fatG}], goalCalories } instead of the single-day shape.") }) }, responses: { 200: { description: "Diary or trend", content: { "application/json": { schema: z.union([NutritionDiaryRef, NutritionTrendRef]) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "get", path: "/api/v1/users/me/nutrition-goals", summary: "Get your daily nutrition goals", security, responses: { 200: { description: "Goals", content: { "application/json": { schema: z.object({ data: NutritionGoalsRef2 }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
registry.registerPath({ method: "put", path: "/api/v1/users/me/nutrition-goals", summary: "Set your daily nutrition goals", security, request: { body: { content: { "application/json": { schema: UpdateNutritionGoalsRef } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Invalid request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
@@ -593,9 +594,9 @@ export function generateOpenApiSpec(): object {
provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), createdAt: z.string().datetime(),
}).describe("Never includes the encrypted key value itself — only which providers are configured."));
- registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API. Requires developer access (an admin must enable it for your account).", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
- registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response. Requires developer access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
- registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", description: "Requires developer access (an admin must enable it for your account).", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "Developer access required", content: { "application/json": { schema: ApiErrorRef } } } } });
+ registry.registerPath({ method: "get", path: "/api/v1/ai-keys", summary: "List your configured AI provider keys", description: "Returns only provider + createdAt — the encrypted key value is never exposed via the API. Requires BYOK access (an admin must enable it for your account) — separate from developer access, which only covers webhooks/API keys.", security, responses: { 200: { description: "Configured providers", content: { "application/json": { schema: z.object({ keys: z.array(AiKeyRef) }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } });
+ registry.registerPath({ method: "post", path: "/api/v1/ai-keys", summary: "Set (or replace) your API key for a provider", description: "Rate-limited: 5 req/hour. The key is encrypted at rest; it is never echoed back in any response. Requires BYOK access (an admin must enable it for your account).", security, request: { body: { content: { "application/json": { schema: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]), apiKey: z.string().min(1).max(500) }) } }, required: true } }, responses: { 200: { description: "Saved", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 400: { description: "Validation error", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } });
+ registry.registerPath({ method: "delete", path: "/api/v1/ai-keys/{provider}", summary: "Remove your stored key for a provider", description: "Requires BYOK access (an admin must enable it for your account).", security, request: { params: z.object({ provider: z.enum(["openai", "anthropic", "openrouter", "ollama"]) }) }, responses: { 200: { description: "Removed", content: { "application/json": { schema: z.object({ ok: z.boolean() }) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 403: { description: "BYOK access required", content: { "application/json": { schema: ApiErrorRef } } } } });
// --- Webhooks: outbound event notifications to a user-provided URL, plus delivery log/redelivery ---
const WebhookEventEnum = z.enum(["recipe.created", "recipe.updated", "recipe.published", "recipe.deleted", "meal_plan.updated", "shopping_list.completed", "comment.added"]);
@@ -879,10 +880,11 @@ export function generateOpenApiSpec(): object {
const AdminUpdateUserBodyRef = registry.register("AdminUpdateUserBody", z.object({
role: z.enum(["user", "moderator", "admin"]).optional(), tier: z.enum(["free", "pro", "family"]).optional(),
- isDeveloper: z.boolean().optional().describe("Gates webhooks, self-serve API keys, and BYOK AI provider keys."),
+ isDeveloper: z.boolean().optional().describe("Gates webhooks and self-serve API keys. Paid-tier users can also self-enable this (PATCH /api/v1/users/me/developer-access)."),
+ isByokEnabled: z.boolean().optional().describe("Gates BYOK AI provider keys. Admin-only, no self-serve path."),
}));
const AdminUpdatedUserRef = registry.register("AdminUpdatedUser", z.object({
- user: z.object({ id: z.string(), role: z.string(), tier: z.string(), isDeveloper: z.boolean() }),
+ user: z.object({ id: z.string(), role: z.string(), tier: z.string(), isDeveloper: z.boolean(), isByokEnabled: z.boolean() }),
}));
const AdminUserUsageRef = registry.register("AdminUserUsage", z.object({
diff --git a/apps/web/lib/permissions.ts b/apps/web/lib/permissions.ts
index b843246..9ef0f04 100644
--- a/apps/web/lib/permissions.ts
+++ b/apps/web/lib/permissions.ts
@@ -1,8 +1,22 @@
-/** Gates webhooks, self-serve API keys, and BYOK AI provider keys.
- * Admin-granted only for now (users.isDeveloper, toggled in
- * admin/users/[id]) — kept as a single function so a future
- * subscription-based auto-grant (e.g. `|| tier !== "free"`) is a one-line
- * change here, not a redesign across every call site. */
+/** Gates webhooks and self-serve API keys. Admin-settable always
+ * (users.isDeveloper, toggled in admin/users/[id]); also self-serve
+ * toggleable by the user themselves once on a paid tier, no added fee —
+ * see PATCH /api/v1/users/me/developer-access. Both paths write the same
+ * column, so this check doesn't need to know which one granted it. */
export function hasDeveloperAccess(user: { isDeveloper: boolean }): boolean {
return user.isDeveloper;
}
+
+/** Gates BYOK AI provider keys — deliberately separate from
+ * hasDeveloperAccess: routing real AI provider spend through Epicure on
+ * the user's own key warrants a manual admin check-in, not blanket
+ * self-serve like webhooks/API keys get. Admin-only, no self-serve path. */
+export function hasByokAccess(user: { isByokEnabled: boolean }): boolean {
+ return user.isByokEnabled;
+}
+
+/** Self-serve developer-access opt-in (webhooks + API keys) is only open
+ * to paid tiers — free-tier users still need an admin grant. */
+export function canSelfServeDeveloperAccess(user: { tier: string }): boolean {
+ return user.tier !== "free";
+}
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 8e90d0d..d9f2f79 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -420,7 +420,9 @@
"nutrition": "Nutrition",
"apiKeys": "API Keys",
"webhooks": "Webhooks",
- "developerLockedNotice": "Developer access required. Ask an admin to enable it for your account.",
+ "developerLockedNotice": "Developer access required. Ask an admin to enable it for your account, or upgrade to a paid plan to enable it yourself.",
+ "developerSelfServeNotice": "Enable developer access to use webhooks and API keys — included free with your plan, no extra charge.",
+ "byokLockedNotice": "BYOK access required. Ask an admin to enable it for your account.",
"usage": {
"title": "Usage & limits",
"description": "AI calls reset monthly ({month}). Recipes and storage are lifetime totals.",
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 1a6c0a3..a6fe5b1 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -420,7 +420,9 @@
"nutrition": "Nutrition",
"apiKeys": "Clés API",
"webhooks": "Webhooks",
- "developerLockedNotice": "Accès développeur requis. Demandez à un administrateur de l'activer pour votre compte.",
+ "developerLockedNotice": "Accès développeur requis. Demandez à un administrateur de l'activer pour votre compte, ou passez à un forfait payant pour l'activer vous-même.",
+ "developerSelfServeNotice": "Activez l'accès développeur pour utiliser les webhooks et les clés API — inclus gratuitement avec votre forfait, sans frais supplémentaires.",
+ "byokLockedNotice": "Accès BYOK requis. Demandez à un administrateur de l'activer pour votre compte.",
"usage": {
"title": "Utilisation et limites",
"description": "Les appels IA se réinitialisent chaque mois ({month}). Les recettes et le stockage sont des totaux à vie.",
diff --git a/apps/web/package.json b/apps/web/package.json
index 3bccae3..a0f8540 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
- "version": "0.71.0",
+ "version": "0.72.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/package.json b/package.json
index 57f56c5..fe00ba3 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "epicure",
- "version": "0.71.0",
+ "version": "0.72.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
diff --git a/packages/db/src/migrations/0061_stormy_the_fallen.sql b/packages/db/src/migrations/0061_stormy_the_fallen.sql
new file mode 100644
index 0000000..a5f8500
--- /dev/null
+++ b/packages/db/src/migrations/0061_stormy_the_fallen.sql
@@ -0,0 +1,9 @@
+ALTER TABLE "users" ADD COLUMN "is_byok_enabled" boolean DEFAULT false NOT NULL;
+--> statement-breakpoint
+-- Grandfather in anyone who already has a BYOK AI key configured -- BYOK
+-- is now its own gate (previously covered by is_developer, per the
+-- migration in 0060), so without this backfill every existing BYOK setup
+-- would silently break the moment this migration runs.
+UPDATE "users" SET "is_byok_enabled" = true WHERE "id" IN (
+ SELECT "user_id" FROM "user_ai_keys"
+);
\ No newline at end of file
diff --git a/packages/db/src/migrations/meta/0061_snapshot.json b/packages/db/src/migrations/meta/0061_snapshot.json
new file mode 100644
index 0000000..e9469f4
--- /dev/null
+++ b/packages/db/src/migrations/meta/0061_snapshot.json
@@ -0,0 +1,6039 @@
+{
+ "id": "7d235e78-a711-4a60-96b9-6fcec703b63f",
+ "prevId": "8dffd22a-513f-428a-8868-fd613d70a303",
+ "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
+ },
+ "scope": {
+ "name": "scope",
+ "type": "api_key_scope",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'full'"
+ },
+ "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.two_factors": {
+ "name": "two_factors",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "secret": {
+ "name": "secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "backup_codes": {
+ "name": "backup_codes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "verified": {
+ "name": "verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "two_factors_user_id_users_id_fk": {
+ "name": "two_factors_user_id_users_id_fk",
+ "tableFrom": "two_factors",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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_blocks": {
+ "name": "user_blocks",
+ "schema": "",
+ "columns": {
+ "blocker_id": {
+ "name": "blocker_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "blocked_id": {
+ "name": "blocked_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_blocks_blocker_id_users_id_fk": {
+ "name": "user_blocks_blocker_id_users_id_fk",
+ "tableFrom": "user_blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "blocker_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "user_blocks_blocked_id_users_id_fk": {
+ "name": "user_blocks_blocked_id_users_id_fk",
+ "tableFrom": "user_blocks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "blocked_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_blocks_blocker_id_blocked_id_pk": {
+ "name": "user_blocks_blocker_id_blocked_id_pk",
+ "columns": [
+ "blocker_id",
+ "blocked_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_feature_prefs": {
+ "name": "user_feature_prefs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "nutrition": {
+ "name": "nutrition",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "pantry": {
+ "name": "pantry",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "meal_plan": {
+ "name": "meal_plan",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "shopping_lists": {
+ "name": "shopping_lists",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "collections": {
+ "name": "collections",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "messages": {
+ "name": "messages",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "chatbots": {
+ "name": "chatbots",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_feature_prefs_user_id_users_id_fk": {
+ "name": "user_feature_prefs_user_id_users_id_fk",
+ "tableFrom": "user_feature_prefs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_feature_prefs_user_id_unique": {
+ "name": "user_feature_prefs_user_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id"
+ ]
+ }
+ },
+ "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": {
+ "user_follows_follower_id_following_id_pk": {
+ "name": "user_follows_follower_id_following_id_pk",
+ "columns": [
+ "follower_id",
+ "following_id"
+ ]
+ }
+ },
+ "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_notification_prefs": {
+ "name": "user_notification_prefs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "follow": {
+ "name": "follow",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "comment": {
+ "name": "comment",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "reply": {
+ "name": "reply",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "reaction": {
+ "name": "reaction",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "rating": {
+ "name": "rating",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "mention": {
+ "name": "mention",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "leftover_expiring": {
+ "name": "leftover_expiring",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "shopping_list": {
+ "name": "shopping_list",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "follow_email": {
+ "name": "follow_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "comment_email": {
+ "name": "comment_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "reply_email": {
+ "name": "reply_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "reaction_email": {
+ "name": "reaction_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "rating_email": {
+ "name": "rating_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "mention_email": {
+ "name": "mention_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "leftover_expiring_email": {
+ "name": "leftover_expiring_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "shopping_list_email": {
+ "name": "shopping_list_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "weekly_digest_email": {
+ "name": "weekly_digest_email",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_notification_prefs_user_id_users_id_fk": {
+ "name": "user_notification_prefs_user_id_users_id_fk",
+ "tableFrom": "user_notification_prefs",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_notification_prefs_user_id_unique": {
+ "name": "user_notification_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
+ },
+ "has_custom_avatar": {
+ "name": "has_custom_avatar",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "avatar_size_mb": {
+ "name": "avatar_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "use_gravatar": {
+ "name": "use_gravatar",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "bio": {
+ "name": "bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "private_bio": {
+ "name": "private_bio",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_private": {
+ "name": "is_private",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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'"
+ },
+ "is_developer": {
+ "name": "is_developer",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "is_byok_enabled": {
+ "name": "is_byok_enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "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'"
+ },
+ "two_factor_enabled": {
+ "name": "two_factor_enabled",
+ "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": {},
+ "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_batch_dishes": {
+ "name": "recipe_batch_dishes",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "recipe_id": {
+ "name": "recipe_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
+ },
+ "order": {
+ "name": "order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "fridge_days": {
+ "name": "fridge_days",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "freezer_friendly": {
+ "name": "freezer_friendly",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "freezer_note": {
+ "name": "freezer_note",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "day_of_instructions": {
+ "name": "day_of_instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {
+ "recipe_batch_dishes_recipe_idx": {
+ "name": "recipe_batch_dishes_recipe_idx",
+ "columns": [
+ {
+ "expression": "recipe_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "recipe_batch_dishes_recipe_id_recipes_id_fk": {
+ "name": "recipe_batch_dishes_recipe_id_recipes_id_fk",
+ "tableFrom": "recipe_batch_dishes",
+ "tableTo": "recipes",
+ "columnsFrom": [
+ "recipe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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": {
+ "recipe_ingredients_recipe_idx": {
+ "name": "recipe_ingredients_recipe_idx",
+ "columns": [
+ {
+ "expression": "recipe_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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": {
+ "recipe_notes_recipe_user_idx": {
+ "name": "recipe_notes_recipe_user_idx",
+ "columns": [
+ {
+ "expression": "recipe_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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
+ },
+ "size_mb": {
+ "name": "size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "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
+ },
+ "applies_to": {
+ "name": "applies_to",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ }
+ },
+ "indexes": {
+ "recipe_steps_recipe_idx": {
+ "name": "recipe_steps_recipe_idx",
+ "columns": [
+ {
+ "expression": "recipe_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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
+ },
+ "recipe_type": {
+ "name": "recipe_type",
+ "type": "recipe_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'dish'"
+ },
+ "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
+ },
+ "nutrition_manual": {
+ "name": "nutrition_manual",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 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": "'{}'"
+ },
+ "is_batch_cook": {
+ "name": "is_batch_cook",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "cover_icon": {
+ "name": "cover_icon",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cover_color": {
+ "name": "cover_color",
+ "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": {
+ "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": {}
+ },
+ "recipes_batch_cook_idx": {
+ "name": "recipes_batch_cook_idx",
+ "columns": [
+ {
+ "expression": "is_batch_cook",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "recipes_type_idx": {
+ "name": "recipes_type_idx",
+ "columns": [
+ {
+ "expression": "recipe_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "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_favorites": {
+ "name": "collection_favorites",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "collection_id": {
+ "name": "collection_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "collection_favorites_collection_idx": {
+ "name": "collection_favorites_collection_idx",
+ "columns": [
+ {
+ "expression": "collection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "collection_favorites_user_collection_idx": {
+ "name": "collection_favorites_user_collection_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "collection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "collection_favorites_user_id_users_id_fk": {
+ "name": "collection_favorites_user_id_users_id_fk",
+ "tableFrom": "collection_favorites",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_favorites_collection_id_collections_id_fk": {
+ "name": "collection_favorites_collection_id_collections_id_fk",
+ "tableFrom": "collection_favorites",
+ "tableTo": "collections",
+ "columnsFrom": [
+ "collection_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
+ },
+ "position": {
+ "name": "position",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "collection_recipes_collection_position_idx": {
+ "name": "collection_recipes_collection_position_idx",
+ "columns": [
+ {
+ "expression": "collection_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "position",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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
+ },
+ "notes": {
+ "name": "notes",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'private'"
+ },
+ "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"
+ },
+ "comments_parent_id_comments_id_fk": {
+ "name": "comments_parent_id_comments_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "parent_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
+ },
+ "batch_dish_id": {
+ "name": "batch_dish_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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
+ },
+ "expiry_reminder_sent_at": {
+ "name": "expiry_reminder_sent_at",
+ "type": "timestamp",
+ "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": {}
+ },
+ "cooking_history_batch_dish_idx": {
+ "name": "cooking_history_batch_dish_idx",
+ "columns": [
+ {
+ "expression": "batch_dish_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"
+ },
+ "cooking_history_batch_dish_id_recipe_batch_dishes_id_fk": {
+ "name": "cooking_history_batch_dish_id_recipe_batch_dishes_id_fk",
+ "tableFrom": "cooking_history",
+ "tableTo": "recipe_batch_dishes",
+ "columnsFrom": [
+ "batch_dish_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.notifications": {
+ "name": "notifications",
+ "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": "notification_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "actor_id": {
+ "name": "actor_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "recipe_id": {
+ "name": "recipe_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "read": {
+ "name": "read",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "notifications_user_idx": {
+ "name": "notifications_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": {}
+ },
+ "notifications_user_unread_idx": {
+ "name": "notifications_user_unread_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "read",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "notifications_user_id_users_id_fk": {
+ "name": "notifications_user_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_actor_id_users_id_fk": {
+ "name": "notifications_actor_id_users_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "users",
+ "columnsFrom": [
+ "actor_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_recipe_id_recipes_id_fk": {
+ "name": "notifications_recipe_id_recipes_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "recipes",
+ "columnsFrom": [
+ "recipe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "notifications_comment_id_comments_id_fk": {
+ "name": "notifications_comment_id_comments_id_fk",
+ "tableFrom": "notifications",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_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
+ },
+ "photo_key": {
+ "name": "photo_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "photo_size_mb": {
+ "name": "photo_size_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "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": {}
+ },
+ "ratings_recipe_idx": {
+ "name": "ratings_recipe_idx",
+ "columns": [
+ {
+ "expression": "recipe_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
+ },
+ "batch_dish_id": {
+ "name": "batch_dish_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": {
+ "meal_plan_entries_plan_idx": {
+ "name": "meal_plan_entries_plan_idx",
+ "columns": [
+ {
+ "expression": "meal_plan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "meal_plan_entries_plan_day_meal_idx": {
+ "name": "meal_plan_entries_plan_day_meal_idx",
+ "columns": [
+ {
+ "expression": "meal_plan_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "day",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "meal_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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"
+ },
+ "meal_plan_entries_batch_dish_id_recipe_batch_dishes_id_fk": {
+ "name": "meal_plan_entries_batch_dish_id_recipe_batch_dishes_id_fk",
+ "tableFrom": "meal_plan_entries",
+ "tableTo": "recipe_batch_dishes",
+ "columnsFrom": [
+ "batch_dish_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
+ },
+ "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()"
+ }
+ },
+ "indexes": {
+ "meal_plans_user_idx": {
+ "name": "meal_plans_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "meal_plans_user_week_idx": {
+ "name": "meal_plans_user_week_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "week_start",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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": {
+ "pantry_items_user_idx": {
+ "name": "pantry_items_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "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
+ },
+ "in_pantry": {
+ "name": "in_pantry",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "sort_order": {
+ "name": "sort_order",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "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
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "public_editable": {
+ "name": "public_editable",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "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.reports": {
+ "name": "reports",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "reporter_id": {
+ "name": "reporter_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "report_target_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "reason": {
+ "name": "reason",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "report_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'pending'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "reviewed_by_id": {
+ "name": "reviewed_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "reviewed_at": {
+ "name": "reviewed_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "reports_status_idx": {
+ "name": "reports_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "reports_reporter_id_users_id_fk": {
+ "name": "reports_reporter_id_users_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reporter_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "reports_reviewed_by_id_users_id_fk": {
+ "name": "reports_reviewed_by_id_users_id_fk",
+ "tableFrom": "reports",
+ "tableTo": "users",
+ "columnsFrom": [
+ "reviewed_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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
+ }
+ },
+ "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.admin_webhook_deliveries": {
+ "name": "admin_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": {
+ "admin_webhook_deliveries_webhook_id_admin_webhooks_id_fk": {
+ "name": "admin_webhook_deliveries_webhook_id_admin_webhooks_id_fk",
+ "tableFrom": "admin_webhook_deliveries",
+ "tableTo": "admin_webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.admin_webhooks": {
+ "name": "admin_webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_by_id": {
+ "name": "created_by_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "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": {
+ "admin_webhooks_created_by_id_users_id_fk": {
+ "name": "admin_webhooks_created_by_id_users_id_fk",
+ "tableFrom": "admin_webhooks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "created_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "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
+ },
+ "public.conversation_reads": {
+ "name": "conversation_reads",
+ "schema": "",
+ "columns": {
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "conversation_reads_conversation_id_conversations_id_fk": {
+ "name": "conversation_reads_conversation_id_conversations_id_fk",
+ "tableFrom": "conversation_reads",
+ "tableTo": "conversations",
+ "columnsFrom": [
+ "conversation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "conversation_reads_user_id_users_id_fk": {
+ "name": "conversation_reads_user_id_users_id_fk",
+ "tableFrom": "conversation_reads",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "conversation_reads_conversation_id_user_id_pk": {
+ "name": "conversation_reads_conversation_id_user_id_pk",
+ "columns": [
+ "conversation_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.conversations": {
+ "name": "conversations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_a_id": {
+ "name": "user_a_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_b_id": {
+ "name": "user_b_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "conversations_last_message_idx": {
+ "name": "conversations_last_message_idx",
+ "columns": [
+ {
+ "expression": "last_message_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "conversations_user_a_id_users_id_fk": {
+ "name": "conversations_user_a_id_users_id_fk",
+ "tableFrom": "conversations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_a_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "conversations_user_b_id_users_id_fk": {
+ "name": "conversations_user_b_id_users_id_fk",
+ "tableFrom": "conversations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_b_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "conversations_pair_uniq": {
+ "name": "conversations_pair_uniq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_a_id",
+ "user_b_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.messages": {
+ "name": "messages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "sender_id": {
+ "name": "sender_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()"
+ }
+ },
+ "indexes": {
+ "messages_conversation_idx": {
+ "name": "messages_conversation_idx",
+ "columns": [
+ {
+ "expression": "conversation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "messages_conversation_id_conversations_id_fk": {
+ "name": "messages_conversation_id_conversations_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "conversations",
+ "columnsFrom": [
+ "conversation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "messages_sender_id_users_id_fk": {
+ "name": "messages_sender_id_users_id_fk",
+ "tableFrom": "messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "sender_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.processed_stripe_events": {
+ "name": "processed_stripe_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ai_conversations": {
+ "name": "ai_conversations",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "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": {
+ "ai_conversations_user_idx": {
+ "name": "ai_conversations_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "updated_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "ai_conversations_user_id_users_id_fk": {
+ "name": "ai_conversations_user_id_users_id_fk",
+ "tableFrom": "ai_conversations",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.chat_messages": {
+ "name": "chat_messages",
+ "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": false
+ },
+ "conversation_id": {
+ "name": "conversation_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "role": {
+ "name": "role",
+ "type": "chat_role",
+ "typeSchema": "public",
+ "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()"
+ }
+ },
+ "indexes": {
+ "chat_messages_user_idx": {
+ "name": "chat_messages_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": {}
+ },
+ "chat_messages_recipe_idx": {
+ "name": "chat_messages_recipe_idx",
+ "columns": [
+ {
+ "expression": "recipe_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "chat_messages_conversation_idx": {
+ "name": "chat_messages_conversation_idx",
+ "columns": [
+ {
+ "expression": "conversation_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "chat_messages_user_id_users_id_fk": {
+ "name": "chat_messages_user_id_users_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_messages_recipe_id_recipes_id_fk": {
+ "name": "chat_messages_recipe_id_recipes_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "recipes",
+ "columnsFrom": [
+ "recipe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "chat_messages_conversation_id_ai_conversations_id_fk": {
+ "name": "chat_messages_conversation_id_ai_conversations_id_fk",
+ "tableFrom": "chat_messages",
+ "tableTo": "ai_conversations",
+ "columnsFrom": [
+ "conversation_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.processed_gitea_events": {
+ "name": "processed_gitea_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.support_ticket_attachments": {
+ "name": "support_ticket_attachments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "ticket_id": {
+ "name": "ticket_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "storage_key": {
+ "name": "storage_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "content_type": {
+ "name": "content_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "size_bytes": {
+ "name": "size_bytes",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "support_ticket_attachments_ticket_idx": {
+ "name": "support_ticket_attachments_ticket_idx",
+ "columns": [
+ {
+ "expression": "ticket_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "support_ticket_attachments_ticket_id_support_tickets_id_fk": {
+ "name": "support_ticket_attachments_ticket_id_support_tickets_id_fk",
+ "tableFrom": "support_ticket_attachments",
+ "tableTo": "support_tickets",
+ "columnsFrom": [
+ "ticket_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.support_ticket_comments": {
+ "name": "support_ticket_comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "ticket_id": {
+ "name": "ticket_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_type": {
+ "name": "author_type",
+ "type": "support_comment_author_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "author_id": {
+ "name": "author_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "body": {
+ "name": "body",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "gitea_comment_id": {
+ "name": "gitea_comment_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "support_ticket_comments_ticket_idx": {
+ "name": "support_ticket_comments_ticket_idx",
+ "columns": [
+ {
+ "expression": "ticket_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "support_ticket_comments_gitea_comment_idx": {
+ "name": "support_ticket_comments_gitea_comment_idx",
+ "columns": [
+ {
+ "expression": "gitea_comment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "support_ticket_comments_ticket_id_support_tickets_id_fk": {
+ "name": "support_ticket_comments_ticket_id_support_tickets_id_fk",
+ "tableFrom": "support_ticket_comments",
+ "tableTo": "support_tickets",
+ "columnsFrom": [
+ "ticket_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.support_tickets": {
+ "name": "support_tickets",
+ "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": "support_ticket_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "status": {
+ "name": "status",
+ "type": "support_ticket_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'open'"
+ },
+ "gitea_issue_url": {
+ "name": "gitea_issue_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitea_issue_number": {
+ "name": "gitea_issue_number",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "gitea_error": {
+ "name": "gitea_error",
+ "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": {
+ "support_tickets_user_idx": {
+ "name": "support_tickets_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": {}
+ },
+ "support_tickets_status_idx": {
+ "name": "support_tickets_status_idx",
+ "columns": [
+ {
+ "expression": "status",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "support_tickets_gitea_issue_idx": {
+ "name": "support_tickets_gitea_issue_idx",
+ "columns": [
+ {
+ "expression": "gitea_issue_number",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "support_tickets_user_id_users_id_fk": {
+ "name": "support_tickets_user_id_users_id_fk",
+ "tableFrom": "support_tickets",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.feature_flags": {
+ "name": "feature_flags",
+ "schema": "",
+ "columns": {
+ "feature_key": {
+ "name": "feature_key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tier": {
+ "name": "tier",
+ "type": "tier",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "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": {
+ "feature_flags_updated_by_id_users_id_fk": {
+ "name": "feature_flags_updated_by_id_users_id_fk",
+ "tableFrom": "feature_flags",
+ "tableTo": "users",
+ "columnsFrom": [
+ "updated_by_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "feature_flags_feature_key_tier_pk": {
+ "name": "feature_flags_feature_key_tier_pk",
+ "columns": [
+ "feature_key",
+ "tier"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.api_key_scope": {
+ "name": "api_key_scope",
+ "schema": "public",
+ "values": [
+ "full",
+ "read"
+ ]
+ },
+ "public.tier": {
+ "name": "tier",
+ "schema": "public",
+ "values": [
+ "free",
+ "pro",
+ "family"
+ ]
+ },
+ "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.recipe_type": {
+ "name": "recipe_type",
+ "schema": "public",
+ "values": [
+ "dish",
+ "drink"
+ ]
+ },
+ "public.visibility": {
+ "name": "visibility",
+ "schema": "public",
+ "values": [
+ "private",
+ "unlisted",
+ "public",
+ "followers"
+ ]
+ },
+ "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.notification_type": {
+ "name": "notification_type",
+ "schema": "public",
+ "values": [
+ "follow",
+ "comment",
+ "reply",
+ "reaction",
+ "rating",
+ "mention"
+ ]
+ },
+ "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"
+ ]
+ },
+ "public.report_status": {
+ "name": "report_status",
+ "schema": "public",
+ "values": [
+ "pending",
+ "reviewed",
+ "dismissed"
+ ]
+ },
+ "public.report_target_type": {
+ "name": "report_target_type",
+ "schema": "public",
+ "values": [
+ "recipe",
+ "comment",
+ "user"
+ ]
+ },
+ "public.chat_role": {
+ "name": "chat_role",
+ "schema": "public",
+ "values": [
+ "user",
+ "assistant"
+ ]
+ },
+ "public.support_comment_author_type": {
+ "name": "support_comment_author_type",
+ "schema": "public",
+ "values": [
+ "user",
+ "admin",
+ "gitea"
+ ]
+ },
+ "public.support_ticket_status": {
+ "name": "support_ticket_status",
+ "schema": "public",
+ "values": [
+ "open",
+ "triaged",
+ "closed"
+ ]
+ },
+ "public.support_ticket_type": {
+ "name": "support_ticket_type",
+ "schema": "public",
+ "values": [
+ "bug",
+ "suggestion",
+ "question"
+ ]
+ }
+ },
+ "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 bc4572e..759a37c 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -428,6 +428,13 @@
"when": 1784706150797,
"tag": "0060_wandering_mongoose",
"breakpoints": true
+ },
+ {
+ "idx": 61,
+ "version": "7",
+ "when": 1784791587563,
+ "tag": "0061_stormy_the_fallen",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts
index 5490439..02a2750 100644
--- a/packages/db/src/schema/users.ts
+++ b/packages/db/src/schema/users.ts
@@ -33,10 +33,16 @@ export const users = pgTable("users", {
role: userRoleEnum("role").notNull().default("user"),
tier: tierEnum("tier").notNull().default("free"),
// Orthogonal to both role (staff hierarchy) and tier (billing plan) —
- // gates webhooks, self-serve API keys, and BYOK AI provider keys.
- // Admin-granted only for now; hasDeveloperAccess() in lib/permissions.ts
- // is the single place a future tier-based auto-grant would be added.
+ // gates webhooks and self-serve API keys. Admin-settable always; also
+ // self-serve toggleable by the user themselves once on a paid tier (no
+ // added fee) via PATCH /api/v1/users/me/developer-access — see
+ // hasDeveloperAccess() in lib/permissions.ts.
isDeveloper: boolean("is_developer").notNull().default(false),
+ // Separate from isDeveloper on purpose — BYOK means routing real AI
+ // provider spend through Epicure on the user's own key, which warrants a
+ // manual admin check-in rather than blanket self-serve. Admin-only, no
+ // self-serve path. See hasByokAccess() in lib/permissions.ts.
+ isByokEnabled: boolean("is_byok_enabled").notNull().default(false),
stripeCustomerId: text("stripe_customer_id").unique(),
unitPref: unitPrefEnum("unit_pref").notNull().default("metric"),
locale: text("locale").notNull().default("en"),