From d2faf98ac18719de63433b65602e47b1b1c6da5a Mon Sep 17 00:00:00 2001 From: Arnaud Date: Thu, 2 Jul 2026 12:12:42 +0200 Subject: [PATCH] fix: resolve TODO.md security/perf/test-coverage backlog MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Fixes the 13-item codebase health scan backlog: wraps meal-plan generation in a transaction, adds missing userId/GIN indexes, fixes an IPv6-parsing gap in the webhook SSRF guard (and an identical duplicated bug in the AI URL-import path, now consolidated onto one implementation), paginates the collections list, dedupes the AI recipe Zod schemas, wires up Stripe tier sync, rate-limits AI key rotation, gets `pnpm typecheck` actually working, and adds test coverage for the previously-untested admin/webhooks routes. Two flagged issues (collection removeRecipeId IDOR, tier-limit race) turned out to already be fixed/non-issues on inspection — noted in TODO.md rather than silently dropped. Co-Authored-By: Claude Sonnet 5 --- TODO.md | 37 + .../v1/admin/settings/__tests__/route.test.ts | 80 + .../admin/test-email/__tests__/route.test.ts | 70 + apps/web/app/api/v1/admin/test-email/route.ts | 7 +- .../admin/users/[id]/__tests__/route.test.ts | 84 + apps/web/app/api/v1/ai-keys/route.ts | 4 + .../app/api/v1/ai/meal-plan/generate/route.ts | 128 +- apps/web/app/api/v1/collections/route.ts | 41 +- .../api/v1/recipes/__tests__/route.test.ts | 6 +- apps/web/app/api/v1/search/route.ts | 3 +- .../v1/webhooks/[id]/__tests__/route.test.ts | 93 + .../[id]/deliveries/__tests__/route.test.ts | 68 + .../[id]/redeliver/__tests__/route.test.ts | 92 + .../api/v1/webhooks/__tests__/route.test.ts | 102 + apps/web/app/api/webhooks/stripe/route.ts | 22 +- apps/web/lib/__tests__/tiers.test.ts | 51 +- .../__tests__/validate-webhook-url.test.ts | 54 + apps/web/lib/__tests__/webhooks.test.ts | 4 +- .../lib/ai/__tests__/resolve-user-key.test.ts | 6 +- apps/web/lib/ai/features/adapt-recipe.ts | 23 +- apps/web/lib/ai/features/generate-recipe.ts | 23 +- apps/web/lib/ai/features/import-photo.ts | 23 +- apps/web/lib/ai/features/import-url.ts | 83 +- apps/web/lib/ai/features/recipe-schema.ts | 25 + apps/web/lib/openapi.ts | 11 +- apps/web/lib/tiers.ts | 35 +- apps/web/lib/validate-webhook-url.ts | 101 +- apps/web/package.json | 2 + packages/api-types/package.json | 3 + packages/db/package.json | 4 +- .../db/src/migrations/0012_sloppy_tigra.sql | 6 + .../src/migrations/0013_damp_richard_fisk.sql | 2 + .../db/src/migrations/meta/0012_snapshot.json | 3291 ++++++++++++++++ .../db/src/migrations/meta/0013_snapshot.json | 3304 +++++++++++++++++ packages/db/src/schema/recipes.ts | 1 + packages/db/src/schema/social.ts | 20 +- packages/db/src/schema/users.ts | 1 + packages/db/tsconfig.json | 3 +- 38 files changed, 7598 insertions(+), 315 deletions(-) create mode 100644 TODO.md create mode 100644 apps/web/app/api/v1/admin/settings/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/admin/test-email/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/admin/users/[id]/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts create mode 100644 apps/web/app/api/v1/webhooks/__tests__/route.test.ts create mode 100644 apps/web/lib/__tests__/validate-webhook-url.test.ts create mode 100644 apps/web/lib/ai/features/recipe-schema.ts create mode 100644 packages/db/src/migrations/0012_sloppy_tigra.sql create mode 100644 packages/db/src/migrations/0013_damp_richard_fisk.sql create mode 100644 packages/db/src/migrations/meta/0012_snapshot.json create mode 100644 packages/db/src/migrations/meta/0013_snapshot.json diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..438de7f --- /dev/null +++ b/TODO.md @@ -0,0 +1,37 @@ +# Known issues / backlog + +Findings from a codebase health scan (security, data integrity, tests, perf, cleanup). All items below are resolved as of this pass. + +## Resolved + +1. ~~**IDOR** — `collections/[id]/route.ts`~~ — investigated: the `PUT` handler's top-level ownership check (`existing = findFirst(id + userId)`) already gates the entire handler, including `removeRecipeId`/`addRecipeId`. Not actually vulnerable. No change made. +2. ~~**Missing transaction** — meal-plan generation~~ — wrapped the per-entry insert sequence in `db.transaction(...)`. +3. ~~**Missing indexes**~~ — added `userId` indexes to `collections`, `collectionMembers`, `cookingHistory`, `ratings`, `favorites`. +4. ~~**Zero test coverage** on `api/v1/admin/*` and `api/v1/webhooks/*`~~ — added Vitest coverage for all 7 route files (role checks, SSRF validation path, redelivery logic). +5. ~~SSRF gap — malformed IPv6~~ — replaced string-prefix heuristics with a proper IPv6 parser (handles `::` compression, IPv4-mapped addresses, fails closed on malformed input). Also found and fixed an identical duplicated bug in `lib/ai/features/import-url.ts`; consolidated both call sites onto the one fixed implementation. +6. ~~Race condition — `checkAndIncrementTierLimit`~~ — investigated: already atomic (single `INSERT ... ON CONFLICT DO UPDATE ... RETURNING`). The old racy `checkTierLimit` was dead code (zero callers) — deleted. +7. ~~N+1 / no pagination — collections list~~ — added `limit`/`offset` pagination, matching the `search` route's pattern. +8. ~~Dietary-tag search — missing index~~ — added a GIN index on `recipes.dietaryTags`; also switched the search filter from `->>` text extraction to `@>` containment so the index is actually used. +9. ~~Duplicated Zod schemas across AI features~~ — extracted shared `dietaryTagsSchema`/`ingredientSchema`/`stepSchema` into `lib/ai/features/recipe-schema.ts`. +10. ~~Stripe webhook stubbed~~ — implemented tier upgrade/downgrade; added `users.stripeCustomerId` to map `customer.subscription.deleted` events back to a user. +11. ~~No rate limit on `ai-keys`~~ — added `applyRateLimit` to the POST handler. +12. ~~`pnpm typecheck` documented but missing~~ — added the script to all three workspace packages; also fixed the pre-existing type errors it exposed (`packages/db` missing `@types/node`, two stale test mocks). +13. ~~Hardcoded `localhost:3001` fallback~~ — now throws a 500 with a clear message if `BETTER_AUTH_URL` is unset, instead of silently generating a broken link. + +# Feature ideas + +Brainstormed extensions building on existing infra (pantry match, meal planning, cooking mode w/ voice, tiers, AI generation, social/collections, print, version history). + +## Done + +1. **Expiry-aware pantry** — done. Pantry schema/UI already had `expiresAt` fully wired (date input, sort, expiry badges); added the missing piece — the canCook page now surfaces a "Use it up" badge on recipes that use soon-expiring pantry items and sorts them to the top. +2. **Shared meal plans/shopping lists** — done. Added `shoppingListMembers`/`mealPlanMembers` tables (viewer/editor roles, mirrors `collectionMembers`), share dialogs, and membership-checked API routes. Meal plans keep their owner-side weekly routes; shared access goes through new `/api/v1/meal-plans/shared/[mealPlanId]` routes since plans are addressed by `(userId, weekStart)`. +3. **PDF cookbook export** — done. `/print/collection/[id]` renders every recipe in a collection with `page-break-after` between them, reusing the existing print-page CSS; browser print-to-PDF produces the file, matching the existing single-recipe/meal-plan print pattern (no new PDF-rendering dependency). +4. **Recipe diff/compare view** — done. Added the `diff` package + `VersionDiffView`; version-history-button now has a "Compare with current" action next to Restore. +5. **Grocery delivery handoff** — done as a stub, per confirmed scope: shopping lists get a "Send to grocery delivery" button that always offers copy-as-text, plus a documented (not live) Instacart adapter gated behind `INSTACART_API_KEY`/`NEXT_PUBLIC_GROCERY_PROVIDER` — real activation needs an actual partner agreement. +6. **Personalized "for you" feed** — done. New `/api/v1/feed/for-you` ranks public recipes by tag/dietary-tag overlap with the user's favorited/highly-rated history (falls back to recency when there's no history yet); third feed tab added. +7. **PWA/offline mode** — done. The service worker and offline fallback already existed; added `manifest.json` + icons and wired them into the root layout metadata so the app is installable. Cache-first on `/cook` already makes previously-visited cooking-mode pages available offline. + +## Also fixed this pass + +- **Mobile responsiveness** — Recipes/Collections/Pantry/Meal Plan/Shopping Lists page headers now stack and wrap instead of clipping buttons off-screen on narrow viewports (`flex-col sm:flex-row` + `flex-wrap`). diff --git a/apps/web/app/api/v1/admin/settings/__tests__/route.test.ts b/apps/web/app/api/v1/admin/settings/__tests__/route.test.ts new file mode 100644 index 0000000..82bc6d7 --- /dev/null +++ b/apps/web/app/api/v1/admin/settings/__tests__/route.test.ts @@ -0,0 +1,80 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockAdminSession = { user: { id: "admin-1", role: "admin" } }; + +vi.mock("next/headers", () => ({ + headers: vi.fn().mockResolvedValue(new Headers()), +})); + +vi.mock("@/lib/auth/server", () => ({ + auth: { api: { getSession: vi.fn() } }, +})); + +vi.mock("@/lib/site-settings", () => ({ + setSiteSetting: vi.fn().mockResolvedValue(undefined), +})); + +const { mockSelectChain, mockInsertValues } = vi.hoisted(() => { + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([{ role: "admin" }]), + }; + return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) }; +}); + +vi.mock("@epicure/db", () => ({ + db: { + select: vi.fn(() => mockSelectChain), + insert: vi.fn(() => ({ values: mockInsertValues })), + }, + users: { id: "id", role: "role" }, + auditLogs: {}, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), +})); + +const { auth } = await import("@/lib/auth/server"); +const { setSiteSetting } = await import("@/lib/site-settings"); +import { PUT } from "../route"; + +function makeRequest(body: unknown) { + return new NextRequest("http://localhost/api/v1/admin/settings", { + method: "PUT", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(auth.api.getSession).mockResolvedValue(mockAdminSession as never); + mockSelectChain.where.mockResolvedValue([{ role: "admin" }]); +}); + +describe("PUT /api/v1/admin/settings", () => { + it("returns 403 when caller is not an admin", async () => { + mockSelectChain.where.mockResolvedValue([{ role: "user" }]); + const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" })); + expect(res.status).toBe(403); + }); + + it("returns 403 when there is no session", async () => { + vi.mocked(auth.api.getSession).mockResolvedValue(null as never); + const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" })); + expect(res.status).toBe(403); + }); + + it("updates allowed keys and writes an audit log", async () => { + const res = await PUT(makeRequest({ OPENAI_API_KEY: "sk-1" })); + expect(res.status).toBe(200); + expect(vi.mocked(setSiteSetting)).toHaveBeenCalledWith("OPENAI_API_KEY", "sk-1", "admin-1"); + expect(mockInsertValues).toHaveBeenCalled(); + }); + + it("silently ignores keys not in the allow-list", async () => { + const res = await PUT(makeRequest({ NOT_A_REAL_KEY: "x" })); + expect(res.status).toBe(200); + expect(vi.mocked(setSiteSetting)).not.toHaveBeenCalled(); + expect(mockInsertValues).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/v1/admin/test-email/__tests__/route.test.ts b/apps/web/app/api/v1/admin/test-email/__tests__/route.test.ts new file mode 100644 index 0000000..e1ce360 --- /dev/null +++ b/apps/web/app/api/v1/admin/test-email/__tests__/route.test.ts @@ -0,0 +1,70 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockAdminSession = { user: { id: "admin-1", role: "admin" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireAdmin: vi.fn(), +})); + +vi.mock("@/lib/email", () => ({ + sendEmail: vi.fn().mockResolvedValue(undefined), + verifyEmailHtml: vi.fn((url: string) => `verify`), +})); + +const { requireAdmin } = await import("@/lib/api-auth"); +const { sendEmail } = await import("@/lib/email"); +import { POST } from "../route"; + +function makeRequest(body: unknown) { + return new NextRequest("http://localhost/api/v1/admin/test-email", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const ORIGINAL_ENV = process.env["BETTER_AUTH_URL"]; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null }); + process.env["BETTER_AUTH_URL"] = "https://epicure.example.com"; +}); + +afterEach(() => { + if (ORIGINAL_ENV === undefined) delete process.env["BETTER_AUTH_URL"]; + else process.env["BETTER_AUTH_URL"] = ORIGINAL_ENV; +}); + +describe("POST /api/v1/admin/test-email", () => { + it("returns 403 when caller is not an admin", async () => { + vi.mocked(requireAdmin).mockResolvedValue({ + session: null, + response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }), + } as never); + + const res = await POST(makeRequest({ to: "user@example.com" })); + expect(res.status).toBe(403); + }); + + it("returns 400 when 'to' is missing", async () => { + const res = await POST(makeRequest({})); + expect(res.status).toBe(400); + }); + + it("returns 500 when BETTER_AUTH_URL is not configured", async () => { + delete process.env["BETTER_AUTH_URL"]; + const res = await POST(makeRequest({ to: "user@example.com" })); + expect(res.status).toBe(500); + expect(sendEmail).not.toHaveBeenCalled(); + }); + + it("sends the test email using the configured base URL", async () => { + const res = await POST(makeRequest({ to: "user@example.com" })); + expect(res.status).toBe(200); + expect(sendEmail).toHaveBeenCalledWith( + expect.objectContaining({ to: "user@example.com" }) + ); + }); +}); diff --git a/apps/web/app/api/v1/admin/test-email/route.ts b/apps/web/app/api/v1/admin/test-email/route.ts index dfb9221..baf6ca4 100644 --- a/apps/web/app/api/v1/admin/test-email/route.ts +++ b/apps/web/app/api/v1/admin/test-email/route.ts @@ -9,11 +9,16 @@ export async function POST(req: NextRequest) { const { to } = await req.json() as { to: string }; if (!to) return NextResponse.json({ error: "Missing 'to'" }, { status: 400 }); + const baseUrl = process.env["BETTER_AUTH_URL"]; + if (!baseUrl) { + return NextResponse.json({ error: "BETTER_AUTH_URL is not configured" }, { status: 500 }); + } + try { await sendEmail({ to, subject: "Epicure — test email", - html: verifyEmailHtml(`${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3001"}/verify-email?token=test`), + html: verifyEmailHtml(`${baseUrl}/verify-email?token=test`), }); return NextResponse.json({ ok: true }); } catch (err) { diff --git a/apps/web/app/api/v1/admin/users/[id]/__tests__/route.test.ts b/apps/web/app/api/v1/admin/users/[id]/__tests__/route.test.ts new file mode 100644 index 0000000..4c801c4 --- /dev/null +++ b/apps/web/app/api/v1/admin/users/[id]/__tests__/route.test.ts @@ -0,0 +1,84 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockAdminSession = { user: { id: "admin-1", role: "admin" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireAdmin: vi.fn(), +})); + +const { mockUpdateChain, mockInsertValues } = vi.hoisted(() => { + const mockUpdateChain = { + set: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + returning: vi.fn().mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]), + }; + return { mockUpdateChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) }; +}); + +vi.mock("@epicure/db", () => ({ + db: { + update: vi.fn(() => mockUpdateChain), + insert: vi.fn(() => ({ values: mockInsertValues })), + }, + users: { id: "id", role: "role", tier: "tier" }, + auditLogs: {}, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), +})); + +const { requireAdmin } = await import("@/lib/api-auth"); +import { PATCH } from "../route"; + +function makeRequest(body: unknown) { + return new NextRequest("http://localhost/api/v1/admin/users/target-1", { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }); +} + +const ctx = { params: Promise.resolve({ id: "target-1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireAdmin).mockResolvedValue({ session: mockAdminSession as never, response: null }); + mockUpdateChain.returning.mockResolvedValue([{ id: "target-1", role: "moderator", tier: "free" }]); +}); + +describe("PATCH /api/v1/admin/users/[id]", () => { + it("returns 403 when caller is not an admin", async () => { + vi.mocked(requireAdmin).mockResolvedValue({ + session: null, + response: new Response(JSON.stringify({ error: "Forbidden" }), { status: 403 }), + } as never); + + const res = await PATCH(makeRequest({ role: "admin" }), ctx); + expect(res.status).toBe(403); + }); + + it("returns 400 for an invalid role", async () => { + const res = await PATCH(makeRequest({ role: "superuser" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 400 for an invalid tier", async () => { + const res = await PATCH(makeRequest({ tier: "enterprise" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when the target user does not exist", async () => { + mockUpdateChain.returning.mockResolvedValue([]); + const res = await PATCH(makeRequest({ role: "moderator" }), ctx); + expect(res.status).toBe(404); + }); + + it("updates the user and writes an audit log", async () => { + const res = await PATCH(makeRequest({ role: "moderator" }), ctx); + expect(res.status).toBe(200); + const body = await res.json() as { user: { id: string } }; + expect(body.user.id).toBe("target-1"); + expect(mockInsertValues).toHaveBeenCalledWith( + expect.objectContaining({ action: "admin.user.update", targetId: "target-1" }) + ); + }); +}); diff --git a/apps/web/app/api/v1/ai-keys/route.ts b/apps/web/app/api/v1/ai-keys/route.ts index 6e0acd6..d951abb 100644 --- a/apps/web/app/api/v1/ai-keys/route.ts +++ b/apps/web/app/api/v1/ai-keys/route.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import { db, userAiKeys, eq, and } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { encrypt } from "@/lib/encrypt"; +import { applyRateLimit } from "@/lib/rate-limit"; const VALID_PROVIDERS = ["openai", "anthropic", "openrouter", "ollama"] as const; @@ -27,6 +28,9 @@ export async function POST(req: Request) { const { session, response } = await requireSession(); if (response) return response; + const limited = await applyRateLimit(`rl:ai-keys:${session!.user.id}`, 5, 3600); + if (limited) return limited; + const body = PostSchema.safeParse(await req.json()); if (!body.success) return NextResponse.json({ error: body.error.flatten() }, { status: 400 }); diff --git a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts index 8364e3d..3e6b4b0 100644 --- a/apps/web/app/api/v1/ai/meal-plan/generate/route.ts +++ b/apps/web/app/api/v1/ai/meal-plan/generate/route.ts @@ -78,71 +78,73 @@ export async function POST(req: NextRequest) { const createdEntries: Array<{ id: string; day: string; mealType: string; recipeId: string; recipeTitle: string }> = []; - for (const entry of plan.entries) { - // Create draft recipe - const recipeId = crypto.randomUUID(); - await db.insert(recipes).values({ - id: recipeId, - authorId: userId, - title: entry.recipe.title, - description: entry.recipe.description, - baseServings: entry.servings, - visibility: "private", - aiGenerated: true, - difficulty: entry.recipe.difficulty ?? null, - prepMins: entry.recipe.prepMins ?? null, - cookMins: entry.recipe.cookMins ?? null, - }); + await db.transaction(async (tx) => { + for (const entry of plan.entries) { + // Create draft recipe + const recipeId = crypto.randomUUID(); + await tx.insert(recipes).values({ + id: recipeId, + authorId: userId, + title: entry.recipe.title, + description: entry.recipe.description, + baseServings: entry.servings, + visibility: "private", + aiGenerated: true, + difficulty: entry.recipe.difficulty ?? null, + prepMins: entry.recipe.prepMins ?? null, + cookMins: entry.recipe.cookMins ?? null, + }); - if (entry.recipe.ingredients.length > 0) { - await db.insert(recipeIngredients).values( - entry.recipe.ingredients.map((ing, i) => ({ - id: crypto.randomUUID(), - recipeId, - rawName: ing.rawName, - quantity: ing.quantity != null ? String(ing.quantity) : null, - unit: ing.unit ?? null, - order: i, - })) - ); + if (entry.recipe.ingredients.length > 0) { + await tx.insert(recipeIngredients).values( + entry.recipe.ingredients.map((ing, i) => ({ + id: crypto.randomUUID(), + recipeId, + rawName: ing.rawName, + quantity: ing.quantity != null ? String(ing.quantity) : null, + unit: ing.unit ?? null, + order: i, + })) + ); + } + + if (entry.recipe.steps.length > 0) { + await tx.insert(recipeSteps).values( + entry.recipe.steps.map((step, i) => ({ + id: crypto.randomUUID(), + recipeId, + instruction: step.instruction, + order: i, + })) + ); + } + + // Remove any existing entry for this day+mealType, then insert new + const existingEntry = await tx.query.mealPlanEntries.findFirst({ + where: and( + eq(mealPlanEntries.mealPlanId, mealPlan!.id), + eq(mealPlanEntries.day, entry.day), + eq(mealPlanEntries.mealType, entry.mealType) + ), + }); + + if (existingEntry) { + await tx.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id)); + } + + const entryId = crypto.randomUUID(); + await tx.insert(mealPlanEntries).values({ + id: entryId, + mealPlanId: mealPlan!.id, + day: entry.day, + mealType: entry.mealType, + recipeId, + servings: entry.servings, + }); + + createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title }); } - - if (entry.recipe.steps.length > 0) { - await db.insert(recipeSteps).values( - entry.recipe.steps.map((step, i) => ({ - id: crypto.randomUUID(), - recipeId, - instruction: step.instruction, - order: i, - })) - ); - } - - // Remove any existing entry for this day+mealType, then insert new - const existingEntry = await db.query.mealPlanEntries.findFirst({ - where: and( - eq(mealPlanEntries.mealPlanId, mealPlan!.id), - eq(mealPlanEntries.day, entry.day), - eq(mealPlanEntries.mealType, entry.mealType) - ), - }); - - if (existingEntry) { - await db.delete(mealPlanEntries).where(eq(mealPlanEntries.id, existingEntry.id)); - } - - const entryId = crypto.randomUUID(); - await db.insert(mealPlanEntries).values({ - id: entryId, - mealPlanId: mealPlan!.id, - day: entry.day, - mealType: entry.mealType, - recipeId, - servings: entry.servings, - }); - - createdEntries.push({ id: entryId, day: entry.day, mealType: entry.mealType, recipeId, recipeTitle: entry.recipe.title }); - } + }); return NextResponse.json({ weekStart: parsed.data.weekStart, entries: createdEntries }); } diff --git a/apps/web/app/api/v1/collections/route.ts b/apps/web/app/api/v1/collections/route.ts index 52e58ee..25daa2c 100644 --- a/apps/web/app/api/v1/collections/route.ts +++ b/apps/web/app/api/v1/collections/route.ts @@ -1,6 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, collections, eq, desc } from "@epicure/db"; +import { db, collections, eq, desc, sql } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; const Schema = z.object({ @@ -9,17 +9,42 @@ const Schema = z.object({ isPublic: z.boolean().default(false), }); -export async function GET(_req: NextRequest) { +export async function GET(req: NextRequest) { const { session, response } = await requireSession(); if (response) return response; - const rows = await db.query.collections.findMany({ - where: eq(collections.userId, session!.user.id), - orderBy: desc(collections.updatedAt), - with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } }, - }); + const { searchParams } = req.nextUrl; - return NextResponse.json(rows); + const limitRaw = searchParams.get("limit"); + const limit = Math.min( + limitRaw !== null && !Number.isNaN(Number(limitRaw)) + ? Math.max(1, Number(limitRaw)) + : 20, + 50 + ); + + const offsetRaw = searchParams.get("offset"); + const offset = + offsetRaw !== null && !Number.isNaN(Number(offsetRaw)) + ? Math.max(0, Number(offsetRaw)) + : 0; + + const where = eq(collections.userId, session!.user.id); + + const [rows, countResult] = await Promise.all([ + db.query.collections.findMany({ + where, + orderBy: desc(collections.updatedAt), + with: { recipes: { limit: 4, with: { recipe: { with: { photos: true } } } } }, + limit, + offset, + }), + db.select({ total: sql`count(*)::int` }).from(collections).where(where), + ]); + + const total = countResult[0]?.total ?? 0; + + return NextResponse.json({ data: rows, total, limit, offset }); } export async function POST(req: NextRequest) { diff --git a/apps/web/app/api/v1/recipes/__tests__/route.test.ts b/apps/web/app/api/v1/recipes/__tests__/route.test.ts index 51535cf..2f5af56 100644 --- a/apps/web/app/api/v1/recipes/__tests__/route.test.ts +++ b/apps/web/app/api/v1/recipes/__tests__/route.test.ts @@ -12,7 +12,7 @@ vi.mock("@/lib/api-auth", () => ({ })); vi.mock("@/lib/tiers", () => ({ - checkTierLimit: vi.fn(), + checkAndIncrementTierLimit: vi.fn(), incrementUsage: vi.fn(), TierLimitError: class TierLimitError extends Error {}, })); @@ -134,9 +134,9 @@ describe("POST /api/v1/recipes", () => { }); it("returns 403 when tier limit reached", async () => { - const { checkTierLimit } = await import("@/lib/tiers"); + const { checkAndIncrementTierLimit } = await import("@/lib/tiers"); const { TierLimitError } = await import("@/lib/tiers"); - vi.mocked(checkTierLimit).mockRejectedValue(new TierLimitError("recipe", "free")); + vi.mocked(checkAndIncrementTierLimit).mockRejectedValue(new TierLimitError("recipe", "free")); const res = await POST(makeRequest("POST", validRecipe)); expect(res.status).toBe(403); diff --git a/apps/web/app/api/v1/search/route.ts b/apps/web/app/api/v1/search/route.ts index f429d52..dc2346a 100644 --- a/apps/web/app/api/v1/search/route.ts +++ b/apps/web/app/api/v1/search/route.ts @@ -85,7 +85,8 @@ export async function GET(req: NextRequest) { } for (const tag of dietaryTags) { - conditions.push(sql`${recipes.dietaryTags}->>${tag} = 'true'`); + // Containment (@>) instead of ->> text extraction so the GIN index on dietaryTags is actually used. + conditions.push(sql`${recipes.dietaryTags} @> ${JSON.stringify({ [tag]: true })}::jsonb`); } const where = and(...conditions); diff --git a/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts new file mode 100644 index 0000000..ecf4c8b --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[id]/__tests__/route.test.ts @@ -0,0 +1,93 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockSession = { user: { id: "user-1" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/validate-webhook-url", () => ({ + validateWebhookUrl: vi.fn().mockResolvedValue(null), +})); + +const { mockSelectChain, mockDeleteChain, mockUpdateChain } = vi.hoisted(() => { + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([{ id: "wh-1" }]), + }; + const mockDeleteChain = { where: vi.fn().mockResolvedValue(undefined) }; + const mockUpdateChain = { set: vi.fn().mockReturnThis(), where: vi.fn().mockResolvedValue(undefined) }; + return { mockSelectChain, mockDeleteChain, mockUpdateChain }; +}); + +vi.mock("@epicure/db", () => ({ + db: { + select: vi.fn(() => mockSelectChain), + delete: vi.fn(() => mockDeleteChain), + update: vi.fn(() => mockUpdateChain), + }, + webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +const { validateWebhookUrl } = await import("@/lib/validate-webhook-url"); +import { DELETE, PATCH } from "../route"; + +function makeRequest(method: string, body?: unknown) { + return new NextRequest("http://localhost/api/v1/webhooks/wh-1", { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +const ctx = { params: Promise.resolve({ id: "wh-1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null }); + vi.mocked(validateWebhookUrl).mockResolvedValue(null); + mockSelectChain.limit.mockResolvedValue([{ id: "wh-1" }]); +}); + +describe("DELETE /api/v1/webhooks/[id]", () => { + it("returns 404 when the webhook does not belong to the caller", async () => { + mockSelectChain.limit.mockResolvedValue([]); + const res = await DELETE(makeRequest("DELETE"), ctx); + expect(res.status).toBe(404); + }); + + it("returns 204 on successful deletion", async () => { + const res = await DELETE(makeRequest("DELETE"), ctx); + expect(res.status).toBe(204); + }); +}); + +describe("PATCH /api/v1/webhooks/[id]", () => { + it("returns 404 when the webhook does not belong to the caller", async () => { + mockSelectChain.limit.mockResolvedValue([]); + const res = await PATCH(makeRequest("PATCH", { active: false }), ctx); + expect(res.status).toBe(404); + }); + + it("returns 400 when the new URL fails SSRF validation", async () => { + vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address"); + const res = await PATCH(makeRequest("PATCH", { url: "http://169.254.169.254/" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 400 when there are no fields to update", async () => { + const res = await PATCH(makeRequest("PATCH", {}), ctx); + expect(res.status).toBe(400); + }); + + it("returns 200 and applies the update", async () => { + const res = await PATCH(makeRequest("PATCH", { active: false }), ctx); + expect(res.status).toBe(200); + expect(mockUpdateChain.set).toHaveBeenCalledWith(expect.objectContaining({ active: false })); + }); +}); diff --git a/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts new file mode 100644 index 0000000..f9b7448 --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[id]/deliveries/__tests__/route.test.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockSession = { user: { id: "user-1" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireSession: vi.fn(), +})); + +const { mockFindFirst, mockSelectChain } = vi.hoisted(() => { + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockReturnThis(), + orderBy: vi.fn().mockReturnThis(), + limit: vi.fn().mockResolvedValue([]), + }; + return { mockFindFirst: vi.fn(), mockSelectChain }; +}); + +vi.mock("@epicure/db", () => ({ + db: { + select: vi.fn(() => mockSelectChain), + query: { webhooks: { findFirst: mockFindFirst } }, + }, + webhooks: { id: "id", userId: "user_id" }, + webhookDeliveries: { webhookId: "webhook_id", createdAt: "created_at" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), + desc: vi.fn((a) => ({ a, op: "desc" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +import { GET } from "../route"; + +const ctx = { params: Promise.resolve({ id: "wh-1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null }); + mockFindFirst.mockResolvedValue({ id: "wh-1" }); + mockSelectChain.limit.mockResolvedValue([]); +}); + +describe("GET /api/v1/webhooks/[id]/deliveries", () => { + it("returns 401 when not authenticated", async () => { + vi.mocked(requireSession).mockResolvedValue({ + session: null, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + } as never); + + const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx); + expect(res.status).toBe(401); + }); + + it("returns 404 when the webhook does not belong to the caller", async () => { + mockFindFirst.mockResolvedValue(undefined); + const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx); + expect(res.status).toBe(404); + }); + + it("returns 200 with the delivery history", async () => { + mockSelectChain.limit.mockResolvedValue([{ id: "del-1", event: "recipe.created" }]); + const res = await GET(new NextRequest("http://localhost/api/v1/webhooks/wh-1/deliveries"), ctx); + expect(res.status).toBe(200); + const body = await res.json() as unknown[]; + expect(body).toHaveLength(1); + }); +}); diff --git a/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts new file mode 100644 index 0000000..ec8ed91 --- /dev/null +++ b/apps/web/app/api/v1/webhooks/[id]/redeliver/__tests__/route.test.ts @@ -0,0 +1,92 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockSession = { user: { id: "user-1" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/webhooks", () => ({ + dispatchWebhook: vi.fn().mockResolvedValue(undefined), +})); + +const { mockWebhookFindFirst, mockDeliveryFindFirst } = vi.hoisted(() => ({ + mockWebhookFindFirst: vi.fn(), + mockDeliveryFindFirst: vi.fn(), +})); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + webhooks: { findFirst: mockWebhookFindFirst }, + webhookDeliveries: { findFirst: mockDeliveryFindFirst }, + }, + }, + webhooks: { id: "id", userId: "user_id" }, + webhookDeliveries: { id: "id", webhookId: "webhook_id" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +const { dispatchWebhook } = await import("@/lib/webhooks"); +import { POST } from "../route"; + +const VALID_DELIVERY_ID = "11111111-1111-1111-1111-111111111111"; + +function makeRequest(body?: unknown) { + return new NextRequest("http://localhost/api/v1/webhooks/wh-1/redeliver", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +const ctx = { params: Promise.resolve({ id: "wh-1" }) }; + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null }); + mockWebhookFindFirst.mockResolvedValue({ id: "wh-1" }); + mockDeliveryFindFirst.mockResolvedValue({ + id: VALID_DELIVERY_ID, + event: "recipe.created", + payload: { recipeId: "r-1" }, + }); +}); + +describe("POST /api/v1/webhooks/[id]/redeliver", () => { + it("returns 401 when not authenticated", async () => { + vi.mocked(requireSession).mockResolvedValue({ + session: null, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + } as never); + + const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx); + expect(res.status).toBe(401); + }); + + it("returns 404 when the webhook does not belong to the caller", async () => { + mockWebhookFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx); + expect(res.status).toBe(404); + }); + + it("returns 400 when deliveryId is not a valid UUID", async () => { + const res = await POST(makeRequest({ deliveryId: "not-a-uuid" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when the delivery is not found", async () => { + mockDeliveryFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx); + expect(res.status).toBe(404); + }); + + it("replays the delivery payload via dispatchWebhook", async () => { + const res = await POST(makeRequest({ deliveryId: VALID_DELIVERY_ID }), ctx); + expect(res.status).toBe(200); + expect(dispatchWebhook).toHaveBeenCalledWith("user-1", "recipe.created", { recipeId: "r-1" }); + }); +}); diff --git a/apps/web/app/api/v1/webhooks/__tests__/route.test.ts b/apps/web/app/api/v1/webhooks/__tests__/route.test.ts new file mode 100644 index 0000000..c57937b --- /dev/null +++ b/apps/web/app/api/v1/webhooks/__tests__/route.test.ts @@ -0,0 +1,102 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockSession = { user: { id: "user-1" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/validate-webhook-url", () => ({ + validateWebhookUrl: vi.fn().mockResolvedValue(null), +})); + +const { mockSelectChain, mockInsertValues } = vi.hoisted(() => { + const mockSelectChain = { + from: vi.fn().mockReturnThis(), + where: vi.fn().mockResolvedValue([]), + }; + return { mockSelectChain, mockInsertValues: vi.fn().mockResolvedValue(undefined) }; +}); + +vi.mock("@epicure/db", () => ({ + db: { + select: vi.fn(() => mockSelectChain), + insert: vi.fn(() => ({ values: mockInsertValues })), + }, + webhooks: { id: "id", userId: "user_id", url: "url", events: "events", active: "active", createdAt: "created_at" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +const { validateWebhookUrl } = await import("@/lib/validate-webhook-url"); +import { GET, POST } from "../route"; + +function makeRequest(method: string, body?: unknown) { + return new NextRequest("http://localhost/api/v1/webhooks", { + method, + headers: { "Content-Type": "application/json" }, + body: body ? JSON.stringify(body) : undefined, + }); +} + +beforeEach(() => { + vi.clearAllMocks(); + vi.mocked(requireSession).mockResolvedValue({ session: mockSession as never, response: null }); + vi.mocked(validateWebhookUrl).mockResolvedValue(null); + mockSelectChain.where.mockResolvedValue([]); +}); + +describe("GET /api/v1/webhooks", () => { + it("returns 401 when not authenticated", async () => { + vi.mocked(requireSession).mockResolvedValue({ + session: null, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + } as never); + + const res = await GET(); + expect(res.status).toBe(401); + }); + + it("returns 200 with the user's webhooks", async () => { + mockSelectChain.where.mockResolvedValue([{ id: "wh-1", userId: "user-1" }]); + const res = await GET(); + expect(res.status).toBe(200); + const body = await res.json() as unknown[]; + expect(body).toHaveLength(1); + }); +}); + +describe("POST /api/v1/webhooks", () => { + const validBody = { url: "https://example.com/hook", events: ["recipe.created"] }; + + it("returns 400 on validation error", async () => { + const res = await POST(makeRequest("POST", { url: "" })); + expect(res.status).toBe(400); + }); + + it("returns 400 when the URL fails SSRF validation", async () => { + vi.mocked(validateWebhookUrl).mockResolvedValue("Webhook URL must not point to a private or reserved address"); + const res = await POST(makeRequest("POST", validBody)); + expect(res.status).toBe(400); + }); + + it("returns 401 when not authenticated", async () => { + vi.mocked(requireSession).mockResolvedValue({ + session: null, + response: new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401 }), + } as never); + + const res = await POST(makeRequest("POST", validBody)); + expect(res.status).toBe(401); + }); + + it("returns 201 and creates the webhook", async () => { + const res = await POST(makeRequest("POST", validBody)); + expect(res.status).toBe(201); + const body = await res.json() as { url: string; secret: string }; + expect(body.url).toBe(validBody.url); + expect(body.secret).toBeTruthy(); + expect(mockInsertValues).toHaveBeenCalled(); + }); +}); diff --git a/apps/web/app/api/webhooks/stripe/route.ts b/apps/web/app/api/webhooks/stripe/route.ts index 7b795e0..d05cada 100644 --- a/apps/web/app/api/webhooks/stripe/route.ts +++ b/apps/web/app/api/webhooks/stripe/route.ts @@ -1,5 +1,6 @@ import { NextRequest, NextResponse } from "next/server"; import crypto from "node:crypto"; +import { db, users, eq } from "@epicure/db"; // Stripe webhook handler — verifies stripe-signature header using HMAC-SHA256. // Handles: @@ -74,16 +75,23 @@ export async function POST(req: NextRequest) { return NextResponse.json({ error: "Invalid JSON" }, { status: 400 }); } - // TODO: wire up DB calls when Stripe billing is fully configured switch (event.type) { - case "checkout.session.completed": - // upgrade user to pro - console.log("[stripe-webhook] checkout.session.completed", event.data.object["id"]); + case "checkout.session.completed": { + // client_reference_id is set to our internal userId when the Checkout Session is created. + const userId = event.data.object["client_reference_id"]; + const customerId = event.data.object["customer"]; + if (typeof userId === "string" && typeof customerId === "string") { + await db.update(users).set({ tier: "pro", stripeCustomerId: customerId }).where(eq(users.id, userId)); + } break; - case "customer.subscription.deleted": - // downgrade user to free - console.log("[stripe-webhook] customer.subscription.deleted", event.data.object["id"]); + } + case "customer.subscription.deleted": { + const customerId = event.data.object["customer"]; + if (typeof customerId === "string") { + await db.update(users).set({ tier: "free" }).where(eq(users.stripeCustomerId, customerId)); + } break; + } default: // ignore unhandled event types break; diff --git a/apps/web/lib/__tests__/tiers.test.ts b/apps/web/lib/__tests__/tiers.test.ts index 70c605f..bbc1f00 100644 --- a/apps/web/lib/__tests__/tiers.test.ts +++ b/apps/web/lib/__tests__/tiers.test.ts @@ -4,6 +4,7 @@ import { TierLimitError } from "../tiers"; const mockDb = vi.hoisted(() => ({ select: vi.fn(), insert: vi.fn(), + execute: vi.fn(), })); vi.mock("@epicure/db", () => ({ @@ -22,7 +23,7 @@ vi.mock("@epicure/db", () => ({ })); // Import after mock -const { checkTierLimit, incrementUsage } = await import("../tiers"); +const { checkAndIncrementTierLimit, incrementUsage } = await import("../tiers"); function makeChain(finalValue: unknown) { const chain = { @@ -54,7 +55,7 @@ describe("TierLimitError", () => { }); }); -describe("checkTierLimit", () => { +describe("checkAndIncrementTierLimit", () => { const tierDef = { tier: "free", maxRecipes: 10, @@ -63,50 +64,32 @@ describe("checkTierLimit", () => { maxPublicRecipes: 3, }; - it("does not throw when usage is under limit", async () => { - mockDb.select - .mockReturnValueOnce(makeChain([tierDef])) - .mockReturnValueOnce(makeChain([{ aiCallsUsed: 3, recipeCount: 2, storageUsedMb: 0 }])); + it("does not throw when the atomic upsert returns a row (under limit)", async () => { + mockDb.select.mockReturnValueOnce(makeChain([tierDef])); + mockDb.execute.mockResolvedValueOnce([{ aiCallsUsed: 4 }]); - await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); + await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); }); - it("throws TierLimitError when aiCall limit reached", async () => { - mockDb.select - .mockReturnValueOnce(makeChain([tierDef])) - .mockReturnValueOnce(makeChain([{ aiCallsUsed: 5, recipeCount: 0, storageUsedMb: 0 }])); + it("throws TierLimitError when the upsert's WHERE clause excludes the row (limit reached)", async () => { + mockDb.select.mockReturnValueOnce(makeChain([tierDef])); + mockDb.execute.mockResolvedValueOnce([]); - await expect(checkTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError); + await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).rejects.toThrow(TierLimitError); }); - it("throws TierLimitError when recipe limit reached", async () => { - mockDb.select - .mockReturnValueOnce(makeChain([tierDef])) - .mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 10, storageUsedMb: 0 }])); + it("throws TierLimitError for recipe key when limit reached", async () => { + mockDb.select.mockReturnValueOnce(makeChain([tierDef])); + mockDb.execute.mockResolvedValueOnce([]); - await expect(checkTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError); - }); - - it("does not throw when no usage row exists (treats as zero)", async () => { - mockDb.select - .mockReturnValueOnce(makeChain([tierDef])) - .mockReturnValueOnce(makeChain([])); - - await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); + await expect(checkAndIncrementTierLimit("user1", "free", "recipe")).rejects.toThrow(TierLimitError); }); it("does not throw when tier definition does not exist", async () => { mockDb.select.mockReturnValueOnce(makeChain([])); - await expect(checkTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); - }); - - it("does not throw for storage key (no limit enforced)", async () => { - mockDb.select - .mockReturnValueOnce(makeChain([tierDef])) - .mockReturnValueOnce(makeChain([{ aiCallsUsed: 0, recipeCount: 0, storageUsedMb: 9999 }])); - - await expect(checkTierLimit("user1", "free", "storage")).resolves.toBeUndefined(); + await expect(checkAndIncrementTierLimit("user1", "free", "aiCall")).resolves.toBeUndefined(); + expect(mockDb.execute).not.toHaveBeenCalled(); }); }); diff --git a/apps/web/lib/__tests__/validate-webhook-url.test.ts b/apps/web/lib/__tests__/validate-webhook-url.test.ts new file mode 100644 index 0000000..db4d73e --- /dev/null +++ b/apps/web/lib/__tests__/validate-webhook-url.test.ts @@ -0,0 +1,54 @@ +import { describe, it, expect } from "vitest"; +import { isPrivateAddress } from "../validate-webhook-url"; + +describe("isPrivateAddress", () => { + it("flags IPv4 private/reserved ranges", () => { + expect(isPrivateAddress("127.0.0.1")).toBe(true); + expect(isPrivateAddress("10.1.2.3")).toBe(true); + expect(isPrivateAddress("172.16.0.1")).toBe(true); + expect(isPrivateAddress("192.168.1.1")).toBe(true); + expect(isPrivateAddress("169.254.1.1")).toBe(true); + expect(isPrivateAddress("224.0.0.1")).toBe(true); + }); + + it("allows public IPv4 addresses", () => { + expect(isPrivateAddress("8.8.8.8")).toBe(false); + expect(isPrivateAddress("1.1.1.1")).toBe(false); + }); + + it("fails closed on malformed IPv4 octets", () => { + expect(isPrivateAddress("999.999.999.999")).toBe(true); + }); + + it("flags IPv6 loopback and unspecified addresses in any compression form", () => { + expect(isPrivateAddress("::1")).toBe(true); + expect(isPrivateAddress("::")).toBe(true); + expect(isPrivateAddress("0:0:0:0:0:0:0:1")).toBe(true); + }); + + it("flags IPv6 unique-local (fc00::/7) and link-local (fe80::/10) ranges", () => { + expect(isPrivateAddress("fc00::1")).toBe(true); + expect(isPrivateAddress("fd12:3456::1")).toBe(true); + expect(isPrivateAddress("fdff:ffff:ffff:ffff:ffff:ffff:ffff:ffff")).toBe(true); + expect(isPrivateAddress("fe80::1")).toBe(true); + expect(isPrivateAddress("fe00::1")).toBe(false); + }); + + it("recurses into IPv4-mapped IPv6 addresses, including non-compressed forms", () => { + expect(isPrivateAddress("::ffff:127.0.0.1")).toBe(true); + expect(isPrivateAddress("::ffff:10.0.0.5")).toBe(true); + expect(isPrivateAddress("::ffff:8.8.8.8")).toBe(false); + }); + + it("fails closed on a malformed IPv4-mapped address (out-of-range octets)", () => { + expect(isPrivateAddress("::ffff:999.999.999.999")).toBe(true); + }); + + it("allows public IPv6 addresses", () => { + expect(isPrivateAddress("2001:4860:4860::8888")).toBe(false); + }); + + it("fails closed on unparseable input", () => { + expect(isPrivateAddress("not-an-ip")).toBe(true); + }); +}); diff --git a/apps/web/lib/__tests__/webhooks.test.ts b/apps/web/lib/__tests__/webhooks.test.ts index 6d600d8..129cb7f 100644 --- a/apps/web/lib/__tests__/webhooks.test.ts +++ b/apps/web/lib/__tests__/webhooks.test.ts @@ -22,7 +22,7 @@ vi.mock("@epicure/db", () => ({ let fetchCalls: { url: string; body: string; headers: Record }[] = []; -global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => { +global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const body = (init?.body as string) ?? ""; fetchCalls.push({ url: url as string, @@ -38,7 +38,7 @@ beforeEach(() => { fetchCalls = []; vi.clearAllMocks(); mockInsert.mockReturnValue({ values: mockInsertValues }); - global.fetch = vi.fn(async (url: RequestInfo, init?: RequestInit) => { + global.fetch = vi.fn(async (url: RequestInfo | URL, init?: RequestInit) => { const body = (init?.body as string) ?? ""; fetchCalls.push({ url: url as string, diff --git a/apps/web/lib/ai/__tests__/resolve-user-key.test.ts b/apps/web/lib/ai/__tests__/resolve-user-key.test.ts index 84fbbec..285b1cc 100644 --- a/apps/web/lib/ai/__tests__/resolve-user-key.test.ts +++ b/apps/web/lib/ai/__tests__/resolve-user-key.test.ts @@ -92,7 +92,7 @@ describe("withUserKey", () => { vi.mocked(mockUserAiKeysFindMany); // just ensure mock is ready // withUserKey uses findFirst via userAiKeys const mockFindFirst = vi.fn().mockResolvedValue({ encryptedKey: encKey }); - vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; + ((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; const config = await withUserKey("user1", { provider: "openai" }); expect(config.apiKey).toBe("sk-user-key"); @@ -100,7 +100,7 @@ describe("withUserKey", () => { it("returns config unchanged when no user key for provider", async () => { const mockFindFirst = vi.fn().mockResolvedValue(null); - vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; + ((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; const config = await withUserKey("user1", { provider: "anthropic" }); expect(config.apiKey).toBeUndefined(); @@ -118,7 +118,7 @@ describe("getModelConfigForUseCase", () => { mealPlanModel: null, }); const mockFindFirst = vi.fn().mockResolvedValue(null); // no BYOK key - vi.mocked((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; + ((await import("@epicure/db")).db.query.userAiKeys as unknown as { findFirst: typeof mockFindFirst }).findFirst = mockFindFirst; const config = await getModelConfigForUseCase("user1", "text"); expect(config.provider).toBe("anthropic"); diff --git a/apps/web/lib/ai/features/adapt-recipe.ts b/apps/web/lib/ai/features/adapt-recipe.ts index f21eafa..abc3ab3 100644 --- a/apps/web/lib/ai/features/adapt-recipe.ts +++ b/apps/web/lib/ai/features/adapt-recipe.ts @@ -1,6 +1,7 @@ import { generateObject } from "ai"; import { z } from "zod"; import { resolveModel, type AiConfig } from "../factory"; +import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema"; const AdaptedRecipeSchema = z.object({ title: z.string(), @@ -9,25 +10,9 @@ const AdaptedRecipeSchema = z.object({ prepMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(), difficulty: z.enum(["easy", "medium", "hard"]), - dietaryTags: z.object({ - vegan: z.boolean().optional(), - vegetarian: z.boolean().optional(), - glutenFree: z.boolean().optional(), - dairyFree: z.boolean().optional(), - nutFree: z.boolean().optional(), - halal: z.boolean().optional(), - kosher: z.boolean().optional(), - }), - ingredients: z.array(z.object({ - rawName: z.string(), - quantity: z.number().optional(), - unit: z.string().optional(), - note: z.string().optional(), - })), - steps: z.array(z.object({ - instruction: z.string(), - timerSeconds: z.number().int().optional(), - })), + dietaryTags: dietaryTagsSchema, + ingredients: z.array(ingredientSchema(z.number())), + steps: z.array(stepSchema), adaptationNotes: z.string(), }); diff --git a/apps/web/lib/ai/features/generate-recipe.ts b/apps/web/lib/ai/features/generate-recipe.ts index 3d0bbc5..9c4c2f2 100644 --- a/apps/web/lib/ai/features/generate-recipe.ts +++ b/apps/web/lib/ai/features/generate-recipe.ts @@ -1,6 +1,7 @@ import { generateObject } from "ai"; import { z } from "zod"; import { resolveModel, type AiConfig } from "../factory"; +import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema"; const RecipeOutputSchema = z.object({ title: z.string(), @@ -9,25 +10,9 @@ const RecipeOutputSchema = z.object({ prepMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(), difficulty: z.enum(["easy", "medium", "hard"]), - dietaryTags: z.object({ - vegan: z.boolean().optional(), - vegetarian: z.boolean().optional(), - glutenFree: z.boolean().optional(), - dairyFree: z.boolean().optional(), - nutFree: z.boolean().optional(), - halal: z.boolean().optional(), - kosher: z.boolean().optional(), - }), - ingredients: z.array(z.object({ - rawName: z.string(), - quantity: z.number().optional(), - unit: z.string().optional(), - note: z.string().optional(), - })), - steps: z.array(z.object({ - instruction: z.string(), - timerSeconds: z.number().int().optional(), - })), + dietaryTags: dietaryTagsSchema, + ingredients: z.array(ingredientSchema(z.number())), + steps: z.array(stepSchema), }); export type GeneratedRecipe = z.infer; diff --git a/apps/web/lib/ai/features/import-photo.ts b/apps/web/lib/ai/features/import-photo.ts index e3572e5..679db5a 100644 --- a/apps/web/lib/ai/features/import-photo.ts +++ b/apps/web/lib/ai/features/import-photo.ts @@ -1,6 +1,7 @@ import { generateObject } from "ai"; import { z } from "zod"; import { resolveModel, type AiConfig } from "../factory"; +import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema"; const ImportedRecipeSchema = z.object({ title: z.string(), @@ -9,25 +10,9 @@ const ImportedRecipeSchema = z.object({ prepMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), - dietaryTags: z.object({ - vegan: z.boolean().optional(), - vegetarian: z.boolean().optional(), - glutenFree: z.boolean().optional(), - dairyFree: z.boolean().optional(), - nutFree: z.boolean().optional(), - halal: z.boolean().optional(), - kosher: z.boolean().optional(), - }).optional(), - ingredients: z.array(z.object({ - rawName: z.string(), - quantity: z.string().optional(), - unit: z.string().optional(), - note: z.string().optional(), - })), - steps: z.array(z.object({ - instruction: z.string(), - timerSeconds: z.number().int().optional(), - })), + dietaryTags: dietaryTagsSchema.optional(), + ingredients: z.array(ingredientSchema(z.string())), + steps: z.array(stepSchema), }); export type ImportedRecipe = z.infer; diff --git a/apps/web/lib/ai/features/import-url.ts b/apps/web/lib/ai/features/import-url.ts index 99662d3..b2f37af 100644 --- a/apps/web/lib/ai/features/import-url.ts +++ b/apps/web/lib/ai/features/import-url.ts @@ -1,63 +1,8 @@ import { generateObject } from "ai"; -import dns from "node:dns/promises"; import { z } from "zod"; import { resolveModel, type AiConfig } from "../factory"; - -/** - * Resolves the hostname in `rawUrl` and returns an error string if the - * URL targets a private/reserved address range (SSRF guard), or null if safe. - */ -async function validateImportUrl(rawUrl: string): Promise { - let url: URL; - try { - url = new URL(rawUrl); - } catch { - return "Invalid URL"; - } - - if (url.protocol !== "https:" && url.protocol !== "http:") { - return "URL must use http or https"; - } - - let addresses: string[]; - try { - const results = await dns.lookup(url.hostname, { all: true, family: 0 }); - addresses = results.map((r) => r.address); - } catch { - return "Unable to resolve hostname"; - } - - for (const addr of addresses) { - if (isPrivateAddress(addr)) { - return "URL must not point to a private or reserved address"; - } - } - - return null; -} - -function isPrivateAddress(ip: string): boolean { - const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (v4) { - const [, a, b, c] = v4.map(Number) as [number, number, number, number, number]; - if (a === 127) return true; - if (a === 10) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; - if (a === 169 && b === 254) return true; - if (a >= 224) return true; - return false; - } - const lower = ip.toLowerCase(); - if (lower === "::1") return true; - if (lower === "::") return true; - if (lower.startsWith("fe80:")) return true; - if (lower.startsWith("fc") || lower.startsWith("fd")) return true; - const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (v4mapped) return isPrivateAddress(v4mapped[1]!); - return false; -} - +import { validateWebhookUrl } from "@/lib/validate-webhook-url"; +import { dietaryTagsSchema, ingredientSchema, stepSchema } from "./recipe-schema"; const ImportedRecipeSchema = z.object({ title: z.string(), @@ -66,31 +11,15 @@ const ImportedRecipeSchema = z.object({ prepMins: z.number().int().min(0).optional(), cookMins: z.number().int().min(0).optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional(), - dietaryTags: z.object({ - vegan: z.boolean().optional(), - vegetarian: z.boolean().optional(), - glutenFree: z.boolean().optional(), - dairyFree: z.boolean().optional(), - nutFree: z.boolean().optional(), - halal: z.boolean().optional(), - kosher: z.boolean().optional(), - }).optional(), - ingredients: z.array(z.object({ - rawName: z.string(), - quantity: z.string().optional(), - unit: z.string().optional(), - note: z.string().optional(), - })), - steps: z.array(z.object({ - instruction: z.string(), - timerSeconds: z.number().int().optional(), - })), + dietaryTags: dietaryTagsSchema.optional(), + ingredients: z.array(ingredientSchema(z.string())), + steps: z.array(stepSchema), }); export type ImportedRecipe = z.infer; export async function importFromUrl(url: string, config?: AiConfig): Promise { - const ssrfError = await validateImportUrl(url); + const ssrfError = await validateWebhookUrl(url); if (ssrfError) throw new Error(ssrfError); const res = await fetch(url, { diff --git a/apps/web/lib/ai/features/recipe-schema.ts b/apps/web/lib/ai/features/recipe-schema.ts new file mode 100644 index 0000000..c222187 --- /dev/null +++ b/apps/web/lib/ai/features/recipe-schema.ts @@ -0,0 +1,25 @@ +import { z } from "zod"; + +export const dietaryTagsSchema = z.object({ + vegan: z.boolean().optional(), + vegetarian: z.boolean().optional(), + glutenFree: z.boolean().optional(), + dairyFree: z.boolean().optional(), + nutFree: z.boolean().optional(), + halal: z.boolean().optional(), + kosher: z.boolean().optional(), +}); + +export const stepSchema = z.object({ + instruction: z.string(), + timerSeconds: z.number().int().optional(), +}); + +export function ingredientSchema(quantity: Q) { + return z.object({ + rawName: z.string(), + quantity: quantity.optional(), + unit: z.string().optional(), + note: z.string().optional(), + }); +} diff --git a/apps/web/lib/openapi.ts b/apps/web/lib/openapi.ts index 4798b05..3c6187f 100644 --- a/apps/web/lib/openapi.ts +++ b/apps/web/lib/openapi.ts @@ -107,6 +107,15 @@ export function generateOpenApiSpec(): object { pagination: z.object({ page: z.number(), limit: z.number(), total: z.number(), pages: z.number() }), }); + const PaginatedCollections = z.object({ + data: z.array(CollectionRef), + total: z.number(), + limit: z.number(), + offset: z.number(), + }); + + const LimitOffset = z.object({ limit: z.coerce.number().default(20), offset: z.coerce.number().default(0) }); + const idParam = z.object({ id: z.string() }); registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List recipes", security, request: { query: z.object({ page: z.coerce.number().default(1), limit: z.coerce.number().default(20), visibility: z.enum(["private", "unlisted", "public"]).optional(), q: z.string().optional(), difficulty: z.enum(["easy", "medium", "hard"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); @@ -121,7 +130,7 @@ export function generateOpenApiSpec(): object { registry.registerPath({ method: "post", path: "/api/v1/ai/generate", summary: "Generate recipe from prompt", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ prompt: z.string().min(1) }) } }, required: true } }, responses: { 200: { description: "Generated", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "post", path: "/api/v1/ai/import-url", summary: "Import recipe from URL", description: "Rate-limited: 10 req/min.", security, request: { body: { content: { "application/json": { schema: z.object({ url: z.string().url() }) } }, required: true } }, responses: { 200: { description: "Imported", content: { "application/json": { schema: AiGeneratedRef } } }, 429: { description: "Rate limited", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/feed", summary: "Activity feed (pull-based)", security, request: { query: Pagination }, responses: { 200: { description: "Feed", content: { "application/json": { schema: z.array(RecipeRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); - registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: Pagination }, responses: { 200: { description: "Collections", content: { "application/json": { schema: z.array(CollectionRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); + registry.registerPath({ method: "get", path: "/api/v1/collections", summary: "List collections", security, request: { query: LimitOffset }, responses: { 200: { description: "Paginated collections", content: { "application/json": { schema: PaginatedCollections } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/meal-plans/{weekStart}", summary: "Get meal plan for week", security, request: { params: z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") }) }, responses: { 200: { description: "Meal plan", content: { "application/json": { schema: MealPlanRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/pantry", summary: "List pantry items", security, request: { query: Pagination }, responses: { 200: { description: "Items", content: { "application/json": { schema: z.array(PantryItemRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); registry.registerPath({ method: "get", path: "/api/v1/shopping-lists", summary: "List shopping lists", security, request: { query: Pagination }, responses: { 200: { description: "Lists", content: { "application/json": { schema: z.array(ShoppingListRef) } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } }); diff --git a/apps/web/lib/tiers.ts b/apps/web/lib/tiers.ts index b97c97c..32e1472 100644 --- a/apps/web/lib/tiers.ts +++ b/apps/web/lib/tiers.ts @@ -1,6 +1,6 @@ import { db } from "@epicure/db"; import { tierDefinitions, userUsage } from "@epicure/db"; -import { eq, and, sql } from "@epicure/db"; +import { eq, sql } from "@epicure/db"; function currentMonth() { const now = new Date(); @@ -69,39 +69,6 @@ export async function checkAndIncrementTierLimit( } } -/** @deprecated Use checkAndIncrementTierLimit for recipe/aiCall keys to avoid TOCTOU races. */ -export async function checkTierLimit( - userId: string, - userTier: "free" | "pro", - key: LimitKey -): Promise { - const [tierDef] = await db - .select() - .from(tierDefinitions) - .where(eq(tierDefinitions.tier, userTier)); - - if (!tierDef) return; - - const month = currentMonth(); - const [usage] = await db - .select() - .from(userUsage) - .where(and(eq(userUsage.userId, userId), eq(userUsage.month, month))); - - const current = usage ?? { - aiCallsUsed: 0, - recipeCount: 0, - storageUsedMb: 0, - }; - - if (key === "recipe" && current.recipeCount >= tierDef.maxRecipes) { - throw new TierLimitError("recipe", userTier); - } - if (key === "aiCall" && current.aiCallsUsed >= tierDef.aiCallsPerMonth) { - throw new TierLimitError("aiCall", userTier); - } -} - export async function incrementUsage( userId: string, key: LimitKey, diff --git a/apps/web/lib/validate-webhook-url.ts b/apps/web/lib/validate-webhook-url.ts index eff466d..2a141ae 100644 --- a/apps/web/lib/validate-webhook-url.ts +++ b/apps/web/lib/validate-webhook-url.ts @@ -1,24 +1,91 @@ import dns from "node:dns/promises"; +import net from "node:net"; -function isPrivateAddress(ip: string): boolean { - const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); - if (v4) { - const [, a, b, c] = v4.map(Number) as [number, number, number, number, number]; - if (a === 127) return true; - if (a === 10) return true; - if (a === 172 && b >= 16 && b <= 31) return true; - if (a === 192 && b === 168) return true; - if (a === 169 && b === 254) return true; - if (a >= 224) return true; - return false; +function isPrivateV4(a: number, b: number): boolean { + if (a === 127) return true; + if (a === 10) return true; + if (a === 172 && b >= 16 && b <= 31) return true; + if (a === 192 && b === 168) return true; + if (a === 169 && b === 254) return true; + if (a >= 224) return true; + return false; +} + +/** Expands a valid IPv6 address (any compression form) into 8 16-bit groups as a BigInt. */ +function ipv6ToBigInt(ip: string): bigint | null { + if (net.isIPv6(ip) !== true) return null; + + const [head, tail] = ip.split("::"); + const headParts = head ? head.split(":") : []; + const tailParts = tail ? tail.split(":") : []; + + // An embedded IPv4 tail (e.g. "::ffff:127.0.0.1") occupies the last two hextets. + const expand = (parts: string[]): string[] => { + const last = parts[parts.length - 1]; + if (last && last.includes(".")) { + const octets = last.split(".").map(Number); + if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) { + return []; + } + const hex1 = ((octets[0]! << 8) | octets[1]!).toString(16); + const hex2 = ((octets[2]! << 8) | octets[3]!).toString(16); + return [...parts.slice(0, -1), hex1, hex2]; + } + return parts; + }; + + const expandedHead = expand(headParts); + const expandedTail = expand(tailParts); + + let groups: string[]; + if (ip.includes("::")) { + const missing = 8 - (expandedHead.length + expandedTail.length); + if (missing < 0) return null; + groups = [...expandedHead, ...Array(missing).fill("0"), ...expandedTail]; + } else { + groups = expandedHead; + } + + if (groups.length !== 8) return null; + + let result = BigInt(0); + for (const g of groups) { + const val = parseInt(g || "0", 16); + if (Number.isNaN(val)) return null; + result = (result << BigInt(16)) | BigInt(val); + } + return result; +} + +export function isPrivateAddress(ip: string): boolean { + const v4 = ip.match(/^(\d{1,3})\.(\d{1,3})\.(\d{1,3})\.(\d{1,3})$/); + if (v4) { + const octets = v4.slice(1, 5).map(Number); + if (octets.some((o) => o > 255)) return true; // malformed, fail closed + const [a, b] = octets as [number, number]; + return isPrivateV4(a, b); + } + + const addr = ipv6ToBigInt(ip); + if (addr === null) return true; // unparseable, fail closed + + if (addr === BigInt(0) || addr === BigInt(1)) return true; // :: and ::1 + + const fc00 = BigInt(0xfc00) << BigInt(112); + const fe80 = BigInt(0xfe80) << BigInt(112); + const mask7 = BigInt(0xfe00) << BigInt(112); // /7 mask for fc00::/7 (top 7 bits of the address) + const mask10 = BigInt(0xffc0) << BigInt(112); // /10 mask for fe80::/10 (top 10 bits of the address) + if ((addr & mask7) === (fc00 & mask7)) return true; // unique local fc00::/7 + if ((addr & mask10) === (fe80 & mask10)) return true; // link-local fe80::/10 + + // IPv4-mapped ::ffff:0:0/96 + if (addr >> BigInt(32) === BigInt(0xffff)) { + const embedded = addr & BigInt(0xffffffff); + const a = Number((embedded >> BigInt(24)) & BigInt(0xff)); + const b = Number((embedded >> BigInt(16)) & BigInt(0xff)); + return isPrivateV4(a, b); } - const lower = ip.toLowerCase(); - if (lower === "::1" || lower === "::") return true; - if (lower.startsWith("fe80:")) return true; - if (lower.startsWith("fc") || lower.startsWith("fd")) return true; - const v4mapped = lower.match(/^::ffff:(\d+\.\d+\.\d+\.\d+)$/); - if (v4mapped) return isPrivateAddress(v4mapped[1]!); return false; } diff --git a/apps/web/package.json b/apps/web/package.json index 419365a..a54be41 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -7,6 +7,7 @@ "build": "next build", "start": "next start", "lint": "eslint", + "typecheck": "tsc --noEmit", "test": "vitest run", "test:watch": "vitest", "test:coverage": "vitest run --coverage" @@ -26,6 +27,7 @@ "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", "cmdk": "^1.1.1", + "diff": "^9.0.0", "drizzle-orm": "^0.44.7", "ioredis": "^5.11.1", "lucide-react": "^1.21.0", diff --git a/packages/api-types/package.json b/packages/api-types/package.json index 2574ef8..c4a5154 100644 --- a/packages/api-types/package.json +++ b/packages/api-types/package.json @@ -7,6 +7,9 @@ "exports": { ".": "./src/index.ts" }, + "scripts": { + "typecheck": "tsc --noEmit" + }, "dependencies": { "zod": "^3.25.67" }, diff --git a/packages/db/package.json b/packages/db/package.json index 94ee790..2e187a9 100644 --- a/packages/db/package.json +++ b/packages/db/package.json @@ -12,13 +12,15 @@ "generate": "drizzle-kit generate", "migrate": "drizzle-kit migrate", "studio": "drizzle-kit studio", - "seed": "tsx src/seed.ts" + "seed": "tsx src/seed.ts", + "typecheck": "tsc --noEmit" }, "dependencies": { "drizzle-orm": "^0.44.7", "postgres": "^3.4.7" }, "devDependencies": { + "@types/node": "^20.19.43", "drizzle-kit": "^0.31.1", "tsx": "^4.20.3", "typescript": "^5.8.3" diff --git a/packages/db/src/migrations/0012_sloppy_tigra.sql b/packages/db/src/migrations/0012_sloppy_tigra.sql new file mode 100644 index 0000000..440a309 --- /dev/null +++ b/packages/db/src/migrations/0012_sloppy_tigra.sql @@ -0,0 +1,6 @@ +CREATE INDEX "recipes_dietary_tags_gin" ON "recipes" USING gin ("dietary_tags");--> statement-breakpoint +CREATE INDEX "collection_members_user_idx" ON "collection_members" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "collections_user_idx" ON "collections" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "cooking_history_user_idx" ON "cooking_history" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "favorites_user_idx" ON "favorites" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "ratings_user_idx" ON "ratings" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/0013_damp_richard_fisk.sql b/packages/db/src/migrations/0013_damp_richard_fisk.sql new file mode 100644 index 0000000..e115104 --- /dev/null +++ b/packages/db/src/migrations/0013_damp_richard_fisk.sql @@ -0,0 +1,2 @@ +ALTER TABLE "users" ADD COLUMN "stripe_customer_id" text;--> statement-breakpoint +ALTER TABLE "users" ADD CONSTRAINT "users_stripe_customer_id_unique" UNIQUE("stripe_customer_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0012_snapshot.json b/packages/db/src/migrations/meta/0012_snapshot.json new file mode 100644 index 0000000..7311f47 --- /dev/null +++ b/packages/db/src/migrations/meta/0012_snapshot.json @@ -0,0 +1,3291 @@ +{ + "id": "d46fd641-f14f-44ca-a959-e0dc70ddd157", + "prevId": "718bc694-727f-4942-80b6-fe6db104162b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_bio": { + "name": "private_bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "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" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_dietary_tags_gin": { + "name": "recipes_dietary_tags_gin", + "columns": [ + { + "expression": "dietary_tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_members_user_idx": { + "name": "collection_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_idx": { + "name": "collections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cooking_history_user_idx": { + "name": "cooking_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorites_user_idx": { + "name": "favorites_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ratings_user_idx": { + "name": "ratings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0013_snapshot.json b/packages/db/src/migrations/meta/0013_snapshot.json new file mode 100644 index 0000000..a86fc92 --- /dev/null +++ b/packages/db/src/migrations/meta/0013_snapshot.json @@ -0,0 +1,3304 @@ +{ + "id": "c21de13e-ba41-4de7-9af9-0995f806c66b", + "prevId": "d46fd641-f14f-44ca-a959-e0dc70ddd157", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_bio": { + "name": "private_bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_pref": { + "name": "unit_pref", + "type": "unit_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_stripe_customer_id_unique": { + "name": "users_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_dietary_tags_gin": { + "name": "recipes_dietary_tags_gin", + "columns": [ + { + "expression": "dietary_tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_members_user_idx": { + "name": "collection_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_idx": { + "name": "collections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cooking_history_user_idx": { + "name": "cooking_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorites_user_idx": { + "name": "favorites_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ratings_user_idx": { + "name": "ratings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/schema/recipes.ts b/packages/db/src/schema/recipes.ts index c3da88c..ce722f5 100644 --- a/packages/db/src/schema/recipes.ts +++ b/packages/db/src/schema/recipes.ts @@ -49,6 +49,7 @@ export const recipes = pgTable("recipes", { }, (t) => [ index("recipes_author_idx").on(t.authorId), index("recipes_visibility_idx").on(t.visibility), + index("recipes_dietary_tags_gin").using("gin", t.dietaryTags), ]); export const ingredients = pgTable("ingredients", { diff --git a/packages/db/src/schema/social.ts b/packages/db/src/schema/social.ts index 8c210d7..75fb849 100644 --- a/packages/db/src/schema/social.ts +++ b/packages/db/src/schema/social.ts @@ -25,13 +25,17 @@ export const ratings = pgTable("ratings", { reviewText: text("review_text"), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), -}); +}, (t) => [ + index("ratings_user_idx").on(t.userId), +]); export const favorites = pgTable("favorites", { userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), recipeId: text("recipe_id").notNull().references(() => recipes.id, { onDelete: "cascade" }), createdAt: timestamp("created_at").notNull().defaultNow(), -}); +}, (t) => [ + index("favorites_user_idx").on(t.userId), +]); export const comments = pgTable("comments", { id: text("id").primaryKey(), @@ -53,7 +57,9 @@ export const collections = pgTable("collections", { isPublic: boolean("is_public").notNull().default(false), createdAt: timestamp("created_at").notNull().defaultNow(), updatedAt: timestamp("updated_at").notNull().defaultNow(), -}); +}, (t) => [ + index("collections_user_idx").on(t.userId), +]); export const collectionRecipes = pgTable("collection_recipes", { collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }), @@ -68,7 +74,9 @@ export const cookingHistory = pgTable("cooking_history", { cookedAt: timestamp("cooked_at").notNull().defaultNow(), servings: integer("servings"), notes: text("notes"), -}); +}, (t) => [ + index("cooking_history_user_idx").on(t.userId), +]); export const feedItems = pgTable("feed_items", { id: text("id").primaryKey(), @@ -104,7 +112,9 @@ export const collectionMembers = pgTable("collection_members", { userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), role: collectionMemberRoleEnum("role").notNull().default("viewer"), createdAt: timestamp("created_at").notNull().defaultNow(), -}); +}, (t) => [ + index("collection_members_user_idx").on(t.userId), +]); export const commentReactionTypeEnum = pgEnum("comment_reaction_type", ["like", "love", "laugh", "wow", "sad", "fire"]); diff --git a/packages/db/src/schema/users.ts b/packages/db/src/schema/users.ts index bea4319..9e502f6 100644 --- a/packages/db/src/schema/users.ts +++ b/packages/db/src/schema/users.ts @@ -23,6 +23,7 @@ export const users = pgTable("users", { username: text("username").unique(), role: userRoleEnum("role").notNull().default("user"), tier: tierEnum("tier").notNull().default("free"), + stripeCustomerId: text("stripe_customer_id").unique(), unitPref: unitPrefEnum("unit_pref").notNull().default("metric"), locale: text("locale").notNull().default("en"), createdAt: timestamp("created_at").notNull().defaultNow(), diff --git a/packages/db/tsconfig.json b/packages/db/tsconfig.json index 792172f..eac65a4 100644 --- a/packages/db/tsconfig.json +++ b/packages/db/tsconfig.json @@ -2,7 +2,8 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { "outDir": "./dist", - "rootDir": "./src" + "rootDir": "./src", + "types": ["node"] }, "include": ["src"] }