diff --git a/CHANGELOG.md b/CHANGELOG.md
index ddf88d3..016b8d3 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.12.0 — 2026-07-13 12:52
+
+### Added
+- **Public shopping list links can now allow editing**: turn on "Allow editing" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.
+
## 0.11.0 — 2026-07-13 12:27
### Added
diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx
index 6e89537..c47b401 100644
--- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx
+++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx
@@ -50,7 +50,9 @@ export default async function ShoppingListPage({ params }: Params) {
- {access.role === "owner" &&
}
+ {access.role === "owner" && (
+
+ )}
{m.common.print}
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts
index 2600473..b88a50f 100644
--- a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts
+++ b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingListItems, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { getOptionalSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
@@ -17,11 +17,10 @@ const UpdateItemSchema = z.object({
});
export async function PUT(req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
- if (response) return response;
const { id, itemId } = await params;
+ const session = await getOptionalSession();
- const access = await getShoppingListAccess(id, session!.user.id);
+ const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -54,7 +53,7 @@ export async function PUT(req: NextRequest, { params }: Params) {
columns: { rawName: true },
});
if (item) {
- void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
+ void notifyShoppingListMembers(id, session?.user.id ?? "", session?.user.name ?? null, {
type: "checked",
itemName: item.rawName,
listName: access.list.name,
@@ -66,11 +65,10 @@ export async function PUT(req: NextRequest, { params }: Params) {
}
export async function DELETE(_req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
- if (response) return response;
const { id, itemId } = await params;
+ const session = await getOptionalSession();
- const access = await getShoppingListAccess(id, session!.user.id);
+ const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts
index d401233..e2c8d4f 100644
--- a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts
+++ b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { db, shoppingListItems, eq, and } from "@epicure/db";
-import { requireSession } from "@/lib/api-auth";
+import { getOptionalSession } from "@/lib/api-auth";
import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access";
import { guessAisle } from "@/lib/grocery-categories";
import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
@@ -9,13 +9,14 @@ import { notifyShoppingListMembers } from "@/lib/shopping-list-notify";
// Polled by the list view (components/meal-plan/shopping-list-view.tsx) so
// collaborators see each other's checked/added/reordered items without a
// manual refresh — no push/websocket infra in this app, so a short poll on
-// the same shape the page's initial server-render already uses.
+// the same shape the page's initial server-render already uses. No
+// requireSession here: a public-editable list's own visitors (no account)
+// need this too — getShoppingListAccess resolves that from the link itself.
export async function GET(_req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
- if (response) return response;
const { id } = await params;
+ const session = await getOptionalSession();
- const access = await getShoppingListAccess(id, session!.user.id);
+ const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
const items = await db.query.shoppingListItems.findMany({
@@ -59,11 +60,10 @@ const BulkUpdateSchema = z.object({
type Params = { params: Promise<{ id: string }> };
export async function POST(req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
- if (response) return response;
const { id } = await params;
+ const session = await getOptionalSession();
- const access = await getShoppingListAccess(id, session!.user.id);
+ const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
@@ -84,7 +84,7 @@ export async function POST(req: NextRequest, { params }: Params) {
}))
);
- void notifyShoppingListMembers(id, session!.user.id, session!.user.name, {
+ void notifyShoppingListMembers(id, session?.user.id ?? "", session?.user.name ?? null, {
type: "itemsAdded",
count: parsed.data.items.length,
listName: access.list.name,
@@ -94,11 +94,10 @@ export async function POST(req: NextRequest, { params }: Params) {
}
export async function PATCH(req: NextRequest, { params }: Params) {
- const { session, response } = await requireSession();
- if (response) return response;
const { id } = await params;
+ const session = await getOptionalSession();
- const access = await getShoppingListAccess(id, session!.user.id);
+ const access = await getShoppingListAccess(id, session?.user.id ?? null);
if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 });
if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 });
diff --git a/apps/web/app/api/v1/shopping-lists/[id]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/route.ts
index 70efeac..a7af385 100644
--- a/apps/web/app/api/v1/shopping-lists/[id]/route.ts
+++ b/apps/web/app/api/v1/shopping-lists/[id]/route.ts
@@ -27,6 +27,7 @@ const PatchSchema = z.object({
completed: z.boolean().optional(),
name: z.string().min(1).max(100).optional(),
isPublic: z.boolean().optional(),
+ publicEditable: z.boolean().optional(),
});
export async function PATCH(req: NextRequest, { params }: Params) {
@@ -48,7 +49,17 @@ export async function PATCH(req: NextRequest, { params }: Params) {
if (body.data.isPublic !== undefined) {
if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
- await db.update(shoppingLists).set({ isPublic: body.data.isPublic }).where(eq(shoppingLists.id, id));
+ // Turning the public link off also revokes public editing — otherwise
+ // re-enabling the link later would silently restore write access without
+ // the owner having re-opted into it.
+ await db.update(shoppingLists)
+ .set({ isPublic: body.data.isPublic, ...(body.data.isPublic ? {} : { publicEditable: false }) })
+ .where(eq(shoppingLists.id, id));
+ }
+
+ if (body.data.publicEditable !== undefined) {
+ if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 });
+ await db.update(shoppingLists).set({ publicEditable: body.data.publicEditable }).where(eq(shoppingLists.id, id));
}
if (body.data.completed) {
diff --git a/apps/web/app/s/[id]/page.tsx b/apps/web/app/s/[id]/page.tsx
index 8012ede..64a2690 100644
--- a/apps/web/app/s/[id]/page.tsx
+++ b/apps/web/app/s/[id]/page.tsx
@@ -2,12 +2,13 @@ import type { Metadata } from "next";
import { notFound } from "next/navigation";
import { headers } from "next/headers";
import Link from "next/link";
-import { Globe } from "lucide-react";
+import { Globe, Pencil } from "lucide-react";
import { auth } from "@/lib/auth/server";
import { db, shoppingLists, eq, and } from "@epicure/db";
import { buttonVariants } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import { getMessages, formatMessage } from "@/lib/i18n/server";
+import { ShoppingListView } from "@/components/meal-plan/shopping-list-view";
type Params = { params: Promise<{ id: string }> };
@@ -43,8 +44,8 @@ export default async function PublicShoppingListPage({ params }: Params) {
return (
-
-
{m.publicShoppingList.sharedList}
+ {list.publicEditable ?
:
}
+
{list.publicEditable ? m.publicShoppingList.editableBadge : m.publicShoppingList.sharedList}
{!session && (
{m.publicRecipe.signUpFree}
@@ -63,29 +64,45 @@ export default async function PublicShoppingListPage({ params }: Params) {
- {aisles.map((aisle) => (
-
- {aisles.length > 1 && (
-
{aisle}
- )}
-
- {byAisle[aisle]!.map((item) => (
-
-
-
- {[item.quantity, item.unit].filter(Boolean).join(" ")}
-
- {item.rawName}
-
- ))}
-
-
- ))}
+ {list.publicEditable ? (
+
({
+ id: i.id,
+ rawName: i.rawName,
+ quantity: i.quantity,
+ unit: i.unit,
+ aisle: i.aisle,
+ checked: i.checked,
+ sortOrder: i.sortOrder,
+ }))}
+ />
+ ) : (
+ aisles.map((aisle) => (
+
+ {aisles.length > 1 && (
+
{aisle}
+ )}
+
+ {byAisle[aisle]!.map((item) => (
+
+
+
+ {[item.quantity, item.unit].filter(Boolean).join(" ")}
+
+ {item.rawName}
+
+ ))}
+
+
+ ))
+ )}
{!session && (
diff --git a/apps/web/components/shopping-lists/share-shopping-list-button.tsx b/apps/web/components/shopping-lists/share-shopping-list-button.tsx
index cf63740..e97f270 100644
--- a/apps/web/components/shopping-lists/share-shopping-list-button.tsx
+++ b/apps/web/components/shopping-lists/share-shopping-list-button.tsx
@@ -2,7 +2,7 @@
import { useState } from "react";
import { useTranslations } from "next-intl";
-import { UserPlus, X, Link2, Copy, Check } from "lucide-react";
+import { UserPlus, X, Link2, Copy, Check, Pencil } from "lucide-react";
import { toast } from "sonner";
import {
Dialog,
@@ -41,9 +41,10 @@ interface Member {
interface Props {
listId: string;
initialIsPublic: boolean;
+ initialPublicEditable: boolean;
}
-export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
+export function ShareShoppingListButton({ listId, initialIsPublic, initialPublicEditable }: Props) {
const t = useTranslations("shoppingLists");
const ts = useTranslations("shareDialog");
const tCommon = useTranslations("common");
@@ -54,13 +55,18 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
const [loading, setLoading] = useState(false);
const [inviting, setInviting] = useState(false);
const [isPublic, setIsPublic] = useState(initialIsPublic);
+ const [publicEditable, setPublicEditable] = useState(initialPublicEditable);
const [savingPublic, setSavingPublic] = useState(false);
+ const [savingEditable, setSavingEditable] = useState(false);
const [copied, setCopied] = useState(false);
async function togglePublic(checked: boolean) {
setSavingPublic(true);
const previous = isPublic;
+ const previousEditable = publicEditable;
setIsPublic(checked);
+ // Mirrors the API: turning the link off also revokes public editing.
+ if (!checked) setPublicEditable(false);
try {
const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
method: "PATCH",
@@ -69,16 +75,40 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
});
if (!res.ok) {
setIsPublic(previous);
+ setPublicEditable(previousEditable);
toast.error(ts("publicLinkToggleFailed"));
}
} catch {
setIsPublic(previous);
+ setPublicEditable(previousEditable);
toast.error(ts("publicLinkToggleFailed"));
} finally {
setSavingPublic(false);
}
}
+ async function togglePublicEditable(checked: boolean) {
+ setSavingEditable(true);
+ const previous = publicEditable;
+ setPublicEditable(checked);
+ try {
+ const res = await fetch(`/api/v1/shopping-lists/${listId}`, {
+ method: "PATCH",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ publicEditable: checked }),
+ });
+ if (!res.ok) {
+ setPublicEditable(previous);
+ toast.error(ts("publicLinkToggleFailed"));
+ }
+ } catch {
+ setPublicEditable(previous);
+ toast.error(ts("publicLinkToggleFailed"));
+ } finally {
+ setSavingEditable(false);
+ }
+ }
+
async function copyLink() {
const url = `${window.location.origin}/s/${listId}`;
try {
@@ -180,10 +210,22 @@ export function ShareShoppingListButton({ listId, initialIsPublic }: Props) {
{ void togglePublic(v); }} />
{isPublic && (
- void copyLink()}>
- {copied ? : }
- {copied ? ts("linkCopied") : ts("copyLink")}
-
+ <>
+
+
+
+
+
{ts("publicEditableTitle")}
+
{ts("publicEditableDescription")}
+
+
+
{ void togglePublicEditable(v); }} />
+
+ void copyLink()}>
+ {copied ? : }
+ {copied ? ts("linkCopied") : ts("copyLink")}
+
+ >
)}
diff --git a/apps/web/lib/__tests__/shopping-list-access.test.ts b/apps/web/lib/__tests__/shopping-list-access.test.ts
index 407ee9f..bb5039f 100644
--- a/apps/web/lib/__tests__/shopping-list-access.test.ts
+++ b/apps/web/lib/__tests__/shopping-list-access.test.ts
@@ -48,6 +48,23 @@ describe("getShoppingListAccess", () => {
mockMemberFindFirst.mockResolvedValue(undefined);
expect(await getShoppingListAccess("list-1", "user-2")).toBeNull();
});
+
+ it("grants anonymous editor access via a public-editable link", async () => {
+ mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: true, publicEditable: true });
+ const access = await getShoppingListAccess("list-1", null);
+ expect(access?.role).toBe("editor");
+ });
+
+ it("grants anonymous viewer access via a public read-only link", async () => {
+ mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: true, publicEditable: false });
+ const access = await getShoppingListAccess("list-1", null);
+ expect(access?.role).toBe("viewer");
+ });
+
+ it("denies anonymous access when the list isn't public", async () => {
+ mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner", isPublic: false, publicEditable: false });
+ expect(await getShoppingListAccess("list-1", null)).toBeNull();
+ });
});
describe("canWriteShoppingList", () => {
diff --git a/apps/web/lib/api-auth.ts b/apps/web/lib/api-auth.ts
index 09dd237..3487680 100644
--- a/apps/web/lib/api-auth.ts
+++ b/apps/web/lib/api-auth.ts
@@ -13,6 +13,12 @@ export async function requireSession() {
return { session, response: null };
}
+/** Like requireSession, but never 401s — for endpoints that also accept anonymous
+ * access via a resource-scoped capability (e.g. a public-editable share link). */
+export async function getOptionalSession() {
+ return auth.api.getSession({ headers: await headers() });
+}
+
export async function requireAdmin() {
const { session, response } = await requireSession();
if (response) return { session: null, response };
diff --git a/apps/web/lib/changelog.ts b/apps/web/lib/changelog.ts
index eb02d1f..cf95868 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.11.0";
+export const APP_VERSION = "0.12.0";
export type ChangelogEntry = {
version: string;
@@ -11,6 +11,13 @@ export type ChangelogEntry = {
};
export const CHANGELOG: ChangelogEntry[] = [
+ {
+ version: "0.12.0",
+ date: "2026-07-13 12:52",
+ added: [
+ "**Public shopping list links can now allow editing**: turn on \"Allow editing\" alongside the public link so anyone who has it can check off, add, and reorder items — no account needed. Off by default, and turning off the public link revokes editing too.",
+ ],
+ },
{
version: "0.11.0",
date: "2026-07-13 12:27",
diff --git a/apps/web/lib/shopping-list-access.ts b/apps/web/lib/shopping-list-access.ts
index 9c8ddbd..c253c9f 100644
--- a/apps/web/lib/shopping-list-access.ts
+++ b/apps/web/lib/shopping-list-access.ts
@@ -7,21 +7,32 @@ export type ShoppingListAccess = {
role: ShoppingListRole;
};
-/** Resolves a user's access to a shopping list — owner, or member with their assigned role. Null if no access. */
+/**
+ * Resolves access to a shopping list — owner, member with their assigned role,
+ * or (when `userId` is null, i.e. no session) an anonymous visitor via the
+ * public link: "editor" if the owner enabled public editing, "viewer" if the
+ * list is merely public, else no access. The link's id is the only credential
+ * an anonymous visitor has, so this must never fall through to a real role
+ * for a non-public list.
+ */
export async function getShoppingListAccess(
listId: string,
- userId: string
+ userId: string | null
): Promise {
const list = await db.query.shoppingLists.findFirst({ where: eq(shoppingLists.id, listId) });
if (!list) return null;
- if (list.userId === userId) return { list, role: "owner" };
- const member = await db.query.shoppingListMembers.findFirst({
- where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
- });
- if (!member) return null;
+ if (userId) {
+ if (list.userId === userId) return { list, role: "owner" };
- return { list, role: member.role };
+ const member = await db.query.shoppingListMembers.findFirst({
+ where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)),
+ });
+ if (member) return { list, role: member.role };
+ }
+
+ if (!list.isPublic) return null;
+ return { list, role: list.publicEditable ? "editor" : "viewer" };
}
export function canWriteShoppingList(role: ShoppingListRole): boolean {
diff --git a/apps/web/lib/shopping-list-notify.ts b/apps/web/lib/shopping-list-notify.ts
index 9561981..ca82eb1 100644
--- a/apps/web/lib/shopping-list-notify.ts
+++ b/apps/web/lib/shopping-list-notify.ts
@@ -44,7 +44,10 @@ async function getOtherRecipients(listId: string, actorId: string) {
export async function notifyShoppingListMembers(
listId: string,
actorId: string,
- actorName: string,
+ // null actorName = an anonymous edit via a public-editable share link — no
+ // per-visitor identity exists, so this is rendered as a localized generic
+ // label (per recipient's own locale) rather than a real name.
+ actorName: string | null,
event: { type: "checked" | "itemsAdded"; itemName?: string; count?: number; listName: string }
): Promise {
const recipients = await getOtherRecipients(listId, actorId);
@@ -57,16 +60,17 @@ export async function notifyShoppingListMembers(
if (!(await isNotificationCategoryEnabled(recipient.id, "shoppingList"))) return;
const messages = getMessages(recipient.locale);
+ const displayName = actorName ?? messages.publicShoppingList.anonymousEditor;
const title = messages.notifications.pushTitle.shoppingListUpdate;
const body =
event.type === "checked"
? formatMessage(messages.notifications.detail.shoppingListChecked, {
- name: actorName,
+ name: displayName,
item: event.itemName ?? "",
list: event.listName,
})
: formatMessage(messages.notifications.detail.shoppingListItemsAdded, {
- name: actorName,
+ name: displayName,
countLabel: itemsCountLabel(event.count ?? 1, recipient.locale),
list: event.listName,
});
diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json
index 986bc64..4e8f650 100644
--- a/apps/web/messages/en.json
+++ b/apps/web/messages/en.json
@@ -368,7 +368,9 @@
},
"publicShoppingList": {
"sharedList": "Shared shopping list",
- "wantYourOwn": "Want your own shopping lists?"
+ "wantYourOwn": "Want your own shopping lists?",
+ "anonymousEditor": "Someone with the link",
+ "editableBadge": "Anyone with this link can edit"
},
"publicRecipe": {
"publicRecipeBy": "Public recipe by",
@@ -1018,6 +1020,8 @@
"publicLinkTitle": "Public link",
"publicLinkDescription": "Anyone with the link can view this list.",
"publicLinkToggleFailed": "Could not update the public link",
+ "publicEditableTitle": "Allow editing",
+ "publicEditableDescription": "Anyone with the link can check off, add, and reorder items — no account needed.",
"copyLink": "Copy link",
"linkCopied": "Link copied",
"copyLinkFailed": "Could not copy link"
diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json
index 92a883f..29dc022 100644
--- a/apps/web/messages/fr.json
+++ b/apps/web/messages/fr.json
@@ -368,7 +368,9 @@
},
"publicShoppingList": {
"sharedList": "Liste de courses partagée",
- "wantYourOwn": "Envie de vos propres listes de courses ?"
+ "wantYourOwn": "Envie de vos propres listes de courses ?",
+ "anonymousEditor": "Quelqu'un avec le lien",
+ "editableBadge": "Toute personne avec ce lien peut modifier"
},
"publicRecipe": {
"publicRecipeBy": "Recette publique par",
@@ -1006,6 +1008,8 @@
"publicLinkTitle": "Lien public",
"publicLinkDescription": "Toute personne disposant du lien peut voir cette liste.",
"publicLinkToggleFailed": "Impossible de mettre à jour le lien public",
+ "publicEditableTitle": "Autoriser la modification",
+ "publicEditableDescription": "Toute personne disposant du lien peut cocher, ajouter et réorganiser les articles — aucun compte requis.",
"copyLink": "Copier le lien",
"linkCopied": "Lien copié",
"copyLinkFailed": "Impossible de copier le lien"
diff --git a/apps/web/package.json b/apps/web/package.json
index 27d7501..4e9957f 100644
--- a/apps/web/package.json
+++ b/apps/web/package.json
@@ -1,6 +1,6 @@
{
"name": "@epicure/web",
- "version": "0.11.0",
+ "version": "0.12.0",
"private": true,
"scripts": {
"dev": "next dev",
diff --git a/apps/web/proxy.ts b/apps/web/proxy.ts
index cb7a784..17b3df3 100644
--- a/apps/web/proxy.ts
+++ b/apps/web/proxy.ts
@@ -5,8 +5,15 @@ import { applyRateLimit } from "@/lib/rate-limit";
const PUBLIC_PATHS = ["/login", "/signup", "/verify-email", "/forgot-password", "/reset-password", "/api/auth", "/r/", "/u/", "/s/", "/docs", "/api/v1/openapi.json", "/api/webhooks", "/api/v1/invites/", "/api/internal/"];
const ADMIN_PATHS = ["/admin"];
-// Unauthenticated, publicly-linked pages (shared recipes/shopping lists) —
-// no per-user identity to key on, so rate-limit by IP to deter scraping.
+// A public-editable shopping list's own items endpoint — no session cookie to
+// require, since an anonymous visitor's only credential is the link (the list
+// id) itself. getShoppingListAccess (lib/shopping-list-access.ts) is what
+// actually enforces that the specific list allows this; a non-public list
+// hitting this route just gets a 404/403 from the handler, same as today.
+const SHOPPING_LIST_ITEMS_RE = /^\/api\/v1\/shopping-lists\/[^/]+\/items(\/|$)/;
+
+// Unauthenticated, publicly-linked pages/endpoints (shared recipes/shopping
+// lists) — no per-user identity to key on, so rate-limit by IP to deter abuse.
const IP_RATE_LIMITED_PATHS = ["/r/", "/s/"];
const IP_RATE_LIMIT = 60;
const IP_RATE_LIMIT_WINDOW_SECONDS = 60;
@@ -20,12 +27,17 @@ function getClientIp(request: NextRequest): string {
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
- const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p));
+ const sessionCookie = getSessionCookie(request);
+ const isShoppingListItems = SHOPPING_LIST_ITEMS_RE.test(pathname);
+ // Only treat the items endpoint as public for requests with no session —
+ // an authenticated collaborator's own polling/edits should never be bucketed
+ // into the anonymous-visitor IP rate limit below.
+ const isPublic = PUBLIC_PATHS.some((p) => pathname.startsWith(p)) || (isShoppingListItems && !sessionCookie);
const isAdmin = ADMIN_PATHS.some((p) => pathname.startsWith(p));
const isApi = pathname.startsWith("/api/v1");
if (isPublic) {
- if (IP_RATE_LIMITED_PATHS.some((p) => pathname.startsWith(p))) {
+ if (IP_RATE_LIMITED_PATHS.some((p) => pathname.startsWith(p)) || isShoppingListItems) {
const ip = getClientIp(request);
const limited = await applyRateLimit(`rl:public:${ip}`, IP_RATE_LIMIT, IP_RATE_LIMIT_WINDOW_SECONDS);
if (limited) return limited;
@@ -40,8 +52,6 @@ export async function proxy(request: NextRequest) {
const authHeader = request.headers.get("authorization");
const hasApiKeyHeader = isApi && authHeader?.startsWith("Bearer ek_");
- const sessionCookie = getSessionCookie(request);
-
if (!sessionCookie && !hasApiKeyHeader) {
if (isApi) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
diff --git a/package.json b/package.json
index 149d69a..6cf91d5 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "epicure",
- "version": "0.11.0",
+ "version": "0.12.0",
"private": true,
"scripts": {
"dev": "pnpm --filter web dev",
diff --git a/packages/db/src/migrations/0036_mixed_wither.sql b/packages/db/src/migrations/0036_mixed_wither.sql
new file mode 100644
index 0000000..f0b3405
--- /dev/null
+++ b/packages/db/src/migrations/0036_mixed_wither.sql
@@ -0,0 +1 @@
+ALTER TABLE "shopping_lists" ADD COLUMN "public_editable" boolean DEFAULT false NOT NULL;
\ No newline at end of file
diff --git a/packages/db/src/migrations/meta/0036_snapshot.json b/packages/db/src/migrations/meta/0036_snapshot.json
new file mode 100644
index 0000000..2350c87
--- /dev/null
+++ b/packages/db/src/migrations/meta/0036_snapshot.json
@@ -0,0 +1,4948 @@
+{
+ "id": "5abcc421-2f6e-40c0-b7e4-449747c4454a",
+ "prevId": "060e7703-12fa-41f2-aad8-e942772870d9",
+ "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.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_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
+ },
+ "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
+ },
+ "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'"
+ },
+ "stripe_customer_id": {
+ "name": "stripe_customer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "unit_pref": {
+ "name": "unit_pref",
+ "type": "unit_pref",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'metric'"
+ },
+ "locale": {
+ "name": "locale",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'en'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ },
+ "users_username_unique": {
+ "name": "users_username_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "username"
+ ]
+ },
+ "users_stripe_customer_id_unique": {
+ "name": "users_stripe_customer_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "stripe_customer_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.ingredients": {
+ "name": "ingredients",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "aliases": {
+ "name": "aliases",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "category": {
+ "name": "category",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "known_allergens": {
+ "name": "known_allergens",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "ingredients_name_unique": {
+ "name": "ingredients_name_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "name"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.recipe_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
+ },
+ "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
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'private'"
+ },
+ "source_url": {
+ "name": "source_url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_generated": {
+ "name": "ai_generated",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "ai_model": {
+ "name": "ai_model",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "ai_prompt": {
+ "name": "ai_prompt",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "language": {
+ "name": "language",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "dietary_tags": {
+ "name": "dietary_tags",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false,
+ "default": "'{}'::jsonb"
+ },
+ "dietary_verified": {
+ "name": "dietary_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "nutrition_data": {
+ "name": "nutrition_data",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "difficulty": {
+ "name": "difficulty",
+ "type": "difficulty",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "prep_mins": {
+ "name": "prep_mins",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "cook_mins": {
+ "name": "cook_mins",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tags": {
+ "name": "tags",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "is_batch_cook": {
+ "name": "is_batch_cook",
+ "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": {
+ "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": {}
+ }
+ },
+ "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
+ },
+ "added_at": {
+ "name": "added_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "collection_recipes_collection_id_collections_id_fk": {
+ "name": "collection_recipes_collection_id_collections_id_fk",
+ "tableFrom": "collection_recipes",
+ "tableTo": "collections",
+ "columnsFrom": [
+ "collection_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "collection_recipes_recipe_id_recipes_id_fk": {
+ "name": "collection_recipes_recipe_id_recipes_id_fk",
+ "tableFrom": "collection_recipes",
+ "tableTo": "recipes",
+ "columnsFrom": [
+ "recipe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.collections": {
+ "name": "collections",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "is_public": {
+ "name": "is_public",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "collections_user_idx": {
+ "name": "collections_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "collections_user_id_users_id_fk": {
+ "name": "collections_user_id_users_id_fk",
+ "tableFrom": "collections",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.comment_reactions": {
+ "name": "comment_reactions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "comment_id": {
+ "name": "comment_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "comment_reaction_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "comment_reactions_comment_idx": {
+ "name": "comment_reactions_comment_idx",
+ "columns": [
+ {
+ "expression": "comment_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "comment_reactions_comment_id_comments_id_fk": {
+ "name": "comment_reactions_comment_id_comments_id_fk",
+ "tableFrom": "comment_reactions",
+ "tableTo": "comments",
+ "columnsFrom": [
+ "comment_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comment_reactions_user_id_users_id_fk": {
+ "name": "comment_reactions_user_id_users_id_fk",
+ "tableFrom": "comment_reactions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.comments": {
+ "name": "comments",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "recipe_id": {
+ "name": "recipe_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "parent_id": {
+ "name": "parent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "content": {
+ "name": "content",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "comments_recipe_idx": {
+ "name": "comments_recipe_idx",
+ "columns": [
+ {
+ "expression": "recipe_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "comments_recipe_id_recipes_id_fk": {
+ "name": "comments_recipe_id_recipes_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "recipes",
+ "columnsFrom": [
+ "recipe_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "comments_user_id_users_id_fk": {
+ "name": "comments_user_id_users_id_fk",
+ "tableFrom": "comments",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "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
+ },
+ "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
+ },
+ "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
+ },
+ "recipe_count": {
+ "name": "recipe_count",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "storage_used_mb": {
+ "name": "storage_used_mb",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ }
+ },
+ "indexes": {
+ "user_usage_user_month_idx": {
+ "name": "user_usage_user_month_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "month",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "user_usage_user_id_users_id_fk": {
+ "name": "user_usage_user_id_users_id_fk",
+ "tableFrom": "user_usage",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "user_usage_user_month_uniq": {
+ "name": "user_usage_user_month_uniq",
+ "nullsNotDistinct": false,
+ "columns": [
+ "user_id",
+ "month"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhook_deliveries": {
+ "name": "webhook_deliveries",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "webhook_id": {
+ "name": "webhook_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "event": {
+ "name": "event",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status_code": {
+ "name": "status_code",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "success": {
+ "name": "success",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhook_deliveries_webhook_id_webhooks_id_fk": {
+ "name": "webhook_deliveries_webhook_id_webhooks_id_fk",
+ "tableFrom": "webhook_deliveries",
+ "tableTo": "webhooks",
+ "columnsFrom": [
+ "webhook_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.webhooks": {
+ "name": "webhooks",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "events": {
+ "name": "events",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "secret": {
+ "name": "secret",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "active": {
+ "name": "active",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "webhooks_user_id_users_id_fk": {
+ "name": "webhooks_user_id_users_id_fk",
+ "tableFrom": "webhooks",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "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.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
+ },
+ "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": {}
+ }
+ },
+ "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"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "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"
+ ]
+ },
+ "public.unit_pref": {
+ "name": "unit_pref",
+ "schema": "public",
+ "values": [
+ "metric",
+ "imperial"
+ ]
+ },
+ "public.user_role": {
+ "name": "user_role",
+ "schema": "public",
+ "values": [
+ "user",
+ "moderator",
+ "admin"
+ ]
+ },
+ "public.difficulty": {
+ "name": "difficulty",
+ "schema": "public",
+ "values": [
+ "easy",
+ "medium",
+ "hard"
+ ]
+ },
+ "public.visibility": {
+ "name": "visibility",
+ "schema": "public",
+ "values": [
+ "private",
+ "unlisted",
+ "public"
+ ]
+ },
+ "public.collection_member_role": {
+ "name": "collection_member_role",
+ "schema": "public",
+ "values": [
+ "viewer",
+ "editor"
+ ]
+ },
+ "public.comment_reaction_type": {
+ "name": "comment_reaction_type",
+ "schema": "public",
+ "values": [
+ "like",
+ "love",
+ "laugh",
+ "wow",
+ "sad",
+ "fire"
+ ]
+ },
+ "public.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"
+ ]
+ }
+ },
+ "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 2460f6d..4b2b5f9 100644
--- a/packages/db/src/migrations/meta/_journal.json
+++ b/packages/db/src/migrations/meta/_journal.json
@@ -253,6 +253,13 @@
"when": 1783887815564,
"tag": "0035_gifted_plazm",
"breakpoints": true
+ },
+ {
+ "idx": 36,
+ "version": "7",
+ "when": 1783939302554,
+ "tag": "0036_mixed_wither",
+ "breakpoints": true
}
]
}
\ No newline at end of file
diff --git a/packages/db/src/schema/meal-planning.ts b/packages/db/src/schema/meal-planning.ts
index c636cd9..a6c29cf 100644
--- a/packages/db/src/schema/meal-planning.ts
+++ b/packages/db/src/schema/meal-planning.ts
@@ -61,6 +61,11 @@ export const shoppingLists = pgTable("shopping_lists", {
userId: text("user_id").references(() => users.id, { onDelete: "cascade" }),
name: text("name").notNull(),
isPublic: boolean("is_public").notNull().default(false),
+ // Only meaningful when isPublic is true — lets anyone with the link edit
+ // (check/add/reorder) items with no account, like a Google-Docs-style
+ // "anyone with the link can edit". The link itself (an unguessable id) is
+ // the credential; there's no separate per-visitor identity.
+ publicEditable: boolean("public_editable").notNull().default(false),
generatedAt: timestamp("generated_at"),
createdAt: timestamp("created_at").notNull().defaultNow(),
});