diff --git a/.env.example b/.env.example index 644adf6..de215bf 100644 --- a/.env.example +++ b/.env.example @@ -66,3 +66,7 @@ OPENROUTER_DEFAULT_MODEL=google/gemini-flash-1.5 OPENAI_API_KEY= ANTHROPIC_API_KEY= OLLAMA_BASE_URL=http://localhost:11434 + +# Grocery delivery handoff (optional — without these, shopping lists only offer "copy as text") +NEXT_PUBLIC_GROCERY_PROVIDER= +INSTACART_API_KEY= diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index ee0fea0..506f422 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -1,11 +1,15 @@ import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { headers } from "next/headers"; +import Link from "next/link"; +import { Printer } from "lucide-react"; import { auth } from "@/lib/auth/server"; import { db, collections, eq, and, or } from "@epicure/db"; import { RecipeCard } from "@/components/recipe/recipe-card"; import { ForkCollectionButton } from "@/components/collections/fork-collection-button"; import { ShareCollectionButton } from "@/components/collections/share-collection-button"; +import { buttonVariants } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; type Params = { params: Promise<{ id: string }> }; @@ -30,7 +34,7 @@ export default async function CollectionPage({ params }: Params) { return (
-
+

{col.name}

{col.description &&

{col.description}

} @@ -38,7 +42,13 @@ export default async function CollectionPage({ params }: Params) { {col.recipes.length} recipe{col.recipes.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"}

-
+
+ {col.recipes.length > 0 && ( + + + Export as PDF + + )} {isOwner && } {!isOwner && col.isPublic && ( diff --git a/apps/web/app/(app)/meal-plan/page.tsx b/apps/web/app/(app)/meal-plan/page.tsx index 3464db9..7090923 100644 --- a/apps/web/app/(app)/meal-plan/page.tsx +++ b/apps/web/app/(app)/meal-plan/page.tsx @@ -3,9 +3,10 @@ import { headers } from "next/headers"; import Link from "next/link"; import { ChevronLeft, ChevronRight, ShoppingCart, Printer } from "lucide-react"; import { auth } from "@/lib/auth/server"; -import { db, mealPlans, recipes, eq, and, desc } from "@epicure/db"; +import { db, mealPlans, mealPlanMembers, recipes, eq, and, desc } from "@epicure/db"; import { buttonVariants } from "@/components/ui/button"; import { MealPlanner } from "@/components/meal-plan/meal-planner"; +import { ShareMealPlanButton } from "@/components/meal-plan/share-meal-plan-button"; import { WeeklyNutritionBar } from "@/components/nutrition/weekly-nutrition-bar"; import { cn } from "@/lib/utils"; @@ -47,7 +48,7 @@ export default async function MealPlanPage({ const sunday = addWeeks(monday, 1); sunday.setDate(sunday.getDate() - 1); - const [plan, userRecipes] = await Promise.all([ + const [plan, userRecipes, sharedMemberships] = await Promise.all([ db.query.mealPlans.findFirst({ where: and(eq(mealPlans.userId, session.user.id), eq(mealPlans.weekStart, weekStart)), with: { @@ -61,6 +62,10 @@ export default async function MealPlanPage({ orderBy: desc(recipes.updatedAt), columns: { id: true, title: true }, }), + db.query.mealPlanMembers.findMany({ + where: eq(mealPlanMembers.userId, session.user.id), + with: { mealPlan: { with: { user: true } } }, + }), ]); const entries = (plan?.entries ?? []).map((e) => ({ @@ -76,12 +81,13 @@ export default async function MealPlanPage({ return (
-
+

Meal Plan

{label}

-
+
+ Shopping lists @@ -101,6 +107,26 @@ export default async function MealPlanPage({ + + {sharedMemberships.length > 0 && ( +
+

Shared with you

+
+ {sharedMemberships.map((m) => ( + +
+

{`${m.mealPlan.user?.name ?? "Unknown"}'s plan`}

+

Week of {m.mealPlan.weekStart} · {m.role}

+
+ + ))} +
+
+ )}
); } diff --git a/apps/web/app/(app)/meal-plan/shared/[mealPlanId]/page.tsx b/apps/web/app/(app)/meal-plan/shared/[mealPlanId]/page.tsx new file mode 100644 index 0000000..85bc513 --- /dev/null +++ b/apps/web/app/(app)/meal-plan/shared/[mealPlanId]/page.tsx @@ -0,0 +1,55 @@ +import type { Metadata } from "next"; +import { notFound } from "next/navigation"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, mealPlans, recipes, eq, desc } from "@epicure/db"; +import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access"; +import { SharedMealPlanView } from "@/components/meal-plan/shared-meal-plan-view"; + +type Params = { params: Promise<{ mealPlanId: string }> }; + +export const metadata: Metadata = { title: "Shared Meal Plan" }; + +export default async function SharedMealPlanPage({ params }: Params) { + const { mealPlanId } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + + const access = await getMealPlanAccessById(mealPlanId, session.user.id); + if (!access) notFound(); + + const plan = await db.query.mealPlans.findFirst({ + where: eq(mealPlans.id, mealPlanId), + with: { entries: { with: { recipe: true } }, user: true }, + }); + if (!plan) notFound(); + + const userRecipes = await db.query.recipes.findMany({ + where: eq(recipes.authorId, session.user.id), + orderBy: desc(recipes.updatedAt), + columns: { id: true, title: true }, + }); + + const canEdit = canWriteMealPlan(access.role); + + return ( +
+
+

{`${plan.user?.name ?? "Shared"}'s Meal Plan`}

+

Week of {plan.weekStart} · {access.role}

+
+ ({ + id: e.id, + day: e.day, + mealType: e.mealType, + servings: e.servings, + recipe: e.recipe ? { id: e.recipe.id, title: e.recipe.title } : null, + }))} + /> +
+ ); +} diff --git a/apps/web/app/(app)/recipes/[id]/page.tsx b/apps/web/app/(app)/recipes/[id]/page.tsx index a7bff94..e66bec6 100644 --- a/apps/web/app/(app)/recipes/[id]/page.tsx +++ b/apps/web/app/(app)/recipes/[id]/page.tsx @@ -149,7 +149,23 @@ export default async function RecipePage({ params }: Params) { }))} /> - + ({ + rawName: ing.rawName, + quantity: ing.quantity, + unit: ing.unit, + note: ing.note, + })), + steps: recipe.steps.map((s) => ({ + instruction: s.instruction, + timerSeconds: s.timerSeconds, + })), + }} + /> diff --git a/apps/web/app/(app)/recipes/can-cook/page.tsx b/apps/web/app/(app)/recipes/can-cook/page.tsx index 3547eab..22687b6 100644 --- a/apps/web/app/(app)/recipes/can-cook/page.tsx +++ b/apps/web/app/(app)/recipes/can-cook/page.tsx @@ -8,6 +8,14 @@ import { CanCookContent } from "@/components/recipe/can-cook-content"; export const metadata: Metadata = { title: "What can I cook?" }; +const EXPIRING_WITHIN_DAYS = 3; + +function isExpiringSoon(expiresAt: Date | null): boolean { + if (!expiresAt) return false; + const days = Math.ceil((expiresAt.getTime() - Date.now()) / (1000 * 60 * 60 * 24)); + return days >= 0 && days <= EXPIRING_WITHIN_DAYS; +} + export default async function CanCookPage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; @@ -27,6 +35,12 @@ export default async function CanCookPage() { const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase())); + const expiringSoonKeys = new Set( + pantry + .filter((p) => isExpiringSoon(p.expiresAt)) + .map((p) => p.rawName.toLowerCase()) + ); + const scored = userRecipes .filter((r) => r.ingredients.length > 0) .map((recipe) => { @@ -37,6 +51,9 @@ export default async function CanCookPage() { .filter((ing) => !pantryKeys.has(ing.rawName.toLowerCase())) .map((ing) => ing.rawName) .slice(0, 5); + const usesExpiring = recipe.ingredients + .filter((ing) => expiringSoonKeys.has(ing.rawName.toLowerCase())) + .map((ing) => ing.rawName); const total = recipe.ingredients.length; const cover = recipe.photos?.find((p) => p.isCover) ?? recipe.photos?.[0]; return { @@ -50,9 +67,15 @@ export default async function CanCookPage() { total, pct: Math.round((matched / total) * 100), missing, + usesExpiring, }; }) - .sort((a, b) => b.pct - a.pct); + .sort((a, b) => { + if (a.usesExpiring.length > 0 !== b.usesExpiring.length > 0) { + return a.usesExpiring.length > 0 ? -1 : 1; + } + return b.pct - a.pct; + }); return ; } diff --git a/apps/web/app/(app)/shopping-lists/[id]/page.tsx b/apps/web/app/(app)/shopping-lists/[id]/page.tsx index 16e48de..c6beefc 100644 --- a/apps/web/app/(app)/shopping-lists/[id]/page.tsx +++ b/apps/web/app/(app)/shopping-lists/[id]/page.tsx @@ -4,10 +4,13 @@ import { headers } from "next/headers"; import Link from "next/link"; import { Printer } from "lucide-react"; import { auth } from "@/lib/auth/server"; -import { db, shoppingLists, eq, and } from "@epicure/db"; +import { db, shoppingLists, eq } from "@epicure/db"; import { ShoppingListView } from "@/components/meal-plan/shopping-list-view"; +import { ShareShoppingListButton } from "@/components/shopping-lists/share-shopping-list-button"; +import { GroceryExportButton } from "@/components/shopping-lists/grocery-export-button"; import { buttonVariants } from "@/components/ui/button"; import { cn } from "@/lib/utils"; +import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; type Params = { params: Promise<{ id: string }> }; @@ -18,29 +21,39 @@ export default async function ShoppingListPage({ params }: Params) { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; + const access = await getShoppingListAccess(id, session.user.id); + if (!access) notFound(); + const list = await db.query.shoppingLists.findFirst({ - where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session.user.id)), + where: eq(shoppingLists.id, id), with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } }, }); - if (!list) notFound(); + const canEdit = canWriteShoppingList(access.role); + const instacartEnabled = process.env["NEXT_PUBLIC_GROCERY_PROVIDER"] === "instacart"; + return (
-
+

{list.name}

{list.items.length} items{list.generatedAt ? " · Generated from meal plan" : ""}

- - - Print - +
+ + {access.role === "owner" && } + + + Print + +
({ id: i.id, rawName: i.rawName, diff --git a/apps/web/app/(app)/shopping-lists/page.tsx b/apps/web/app/(app)/shopping-lists/page.tsx index 57e13f2..5d009d6 100644 --- a/apps/web/app/(app)/shopping-lists/page.tsx +++ b/apps/web/app/(app)/shopping-lists/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from "next"; import { headers } from "next/headers"; import { auth } from "@/lib/auth/server"; -import { db, shoppingLists, eq, desc } from "@epicure/db"; +import { db, shoppingLists, shoppingListMembers, eq, desc } from "@epicure/db"; import { ShoppingListsPageContent } from "@/components/shopping-lists/shopping-lists-page-content"; export const metadata: Metadata = { title: "Shopping Lists" }; @@ -10,11 +10,17 @@ export default async function ShoppingListsPage() { const session = await auth.api.getSession({ headers: await headers() }); if (!session) return null; - const lists = await db.query.shoppingLists.findMany({ - where: eq(shoppingLists.userId, session.user.id), - orderBy: desc(shoppingLists.createdAt), - with: { items: { columns: { id: true, checked: true } } }, - }); + const [lists, memberships] = await Promise.all([ + db.query.shoppingLists.findMany({ + where: eq(shoppingLists.userId, session.user.id), + orderBy: desc(shoppingLists.createdAt), + with: { items: { columns: { id: true, checked: true } } }, + }), + db.query.shoppingListMembers.findMany({ + where: eq(shoppingListMembers.userId, session.user.id), + with: { list: { with: { user: true } } }, + }), + ]); return ( i.checked).length, }))} + sharedLists={memberships.map((m) => ({ + id: m.list.id, + name: m.list.name, + ownerName: m.list.user?.name ?? "Unknown", + role: m.role, + }))} /> ); } diff --git a/apps/web/app/api/v1/feed/for-you/route.ts b/apps/web/app/api/v1/feed/for-you/route.ts new file mode 100644 index 0000000..04cd924 --- /dev/null +++ b/apps/web/app/api/v1/feed/for-you/route.ts @@ -0,0 +1,69 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, recipes, users, favorites, ratings, eq, and, ne, gte, notInArray, inArray, desc } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { buildPreferenceMap, rankForYou } from "@/lib/for-you-ranking"; + +export async function GET(req: NextRequest) { + const { session, response } = await requireSession(); + if (response) return response; + const userId = session!.user.id; + + const { searchParams } = new URL(req.url); + const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 50); + + // Recipes the user has favorited, or rated 4+, define their taste profile. + const [favoritedRows, highRatedRows] = await Promise.all([ + db.select({ recipeId: favorites.recipeId }).from(favorites).where(eq(favorites.userId, userId)), + db.select({ recipeId: ratings.recipeId }).from(ratings).where(and(eq(ratings.userId, userId), gte(ratings.score, 4))), + ]); + + const likedIds = [...new Set([...favoritedRows.map((r) => r.recipeId), ...highRatedRows.map((r) => r.recipeId)])]; + + const likedRecipes = likedIds.length > 0 + ? await db.select({ tags: recipes.tags, dietaryTags: recipes.dietaryTags }).from(recipes).where(inArray(recipes.id, likedIds)) + : []; + + const preferences = buildPreferenceMap(likedRecipes); + + const excludeIds = likedIds.length > 0 ? likedIds : ["__none__"]; + + const candidates = await db + .select({ + id: recipes.id, + title: recipes.title, + description: recipes.description, + baseServings: recipes.baseServings, + prepMins: recipes.prepMins, + cookMins: recipes.cookMins, + difficulty: recipes.difficulty, + visibility: recipes.visibility, + aiGenerated: recipes.aiGenerated, + createdAt: recipes.createdAt, + authorId: recipes.authorId, + authorName: users.name, + authorUsername: users.username, + authorAvatarUrl: users.avatarUrl, + tags: recipes.tags, + dietaryTags: recipes.dietaryTags, + }) + .from(recipes) + .innerJoin(users, eq(recipes.authorId, users.id)) + .where(and( + eq(recipes.visibility, "public"), + ne(recipes.authorId, userId), + notInArray(recipes.id, excludeIds) + )) + .orderBy(desc(recipes.createdAt)) + .limit(200); // score a bounded recent window rather than the whole table + + const ranked = preferences.size > 0 + ? rankForYou(candidates, preferences) + : [...candidates].sort((a, b) => b.createdAt.getTime() - a.createdAt.getTime()); + + const data = ranked.slice(0, limit).map(({ tags: _tags, dietaryTags: _dietaryTags, ...r }) => ({ + ...r, + createdAt: r.createdAt.toISOString(), + })); + + return NextResponse.json({ data }); +} diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/members/__tests__/route.test.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/members/__tests__/route.test.ts new file mode 100644 index 0000000..7d8bf80 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/members/__tests__/route.test.ts @@ -0,0 +1,137 @@ +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 { mockPlanFindFirst, mockMemberFindFirst, mockMemberFindMany, mockUserFindFirst, mockInsertValues, mockInsertPlanValues, mockDeleteWhere } = vi.hoisted(() => ({ + mockPlanFindFirst: vi.fn(), + mockMemberFindFirst: vi.fn(), + mockMemberFindMany: vi.fn(), + mockUserFindFirst: vi.fn(), + mockInsertValues: vi.fn().mockResolvedValue(undefined), + mockInsertPlanValues: vi.fn().mockResolvedValue(undefined), + mockDeleteWhere: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + mealPlans: { findFirst: mockPlanFindFirst }, + mealPlanMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany }, + users: { findFirst: mockUserFindFirst }, + }, + insert: vi.fn(() => ({ values: mockInsertValues })), + delete: vi.fn(() => ({ where: mockDeleteWhere })), + }, + mealPlans: { id: "id", userId: "user_id", weekStart: "week_start" }, + mealPlanMembers: { id: "id", mealPlanId: "meal_plan_id", userId: "user_id" }, + users: { id: "id", email: "email" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +import { GET, POST, DELETE } from "../route"; + +const ctx = { params: Promise.resolve({ weekStart: "2026-06-01" }) }; + +function makeRequest(method: string, body?: unknown, search = "") { + return new NextRequest(`http://localhost/api/v1/meal-plans/2026-06-01/members${search}`, { + 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 }); +}); + +describe("GET /api/v1/meal-plans/[weekStart]/members", () => { + it("returns an empty list when the owner has no plan for this week yet", async () => { + mockPlanFindFirst.mockResolvedValue(undefined); + const res = await GET(makeRequest("GET"), ctx); + expect(res.status).toBe(200); + expect(await res.json()).toEqual([]); + }); + + it("returns members for an existing plan", async () => { + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" }); + mockMemberFindMany.mockResolvedValue([ + { id: "m1", userId: "user-2", role: "editor", createdAt: new Date(), user: { name: "Bob", username: null, avatarUrl: null } }, + ]); + const res = await GET(makeRequest("GET"), ctx); + const body = await res.json() as unknown[]; + expect(body).toHaveLength(1); + }); +}); + +describe("POST /api/v1/meal-plans/[weekStart]/members", () => { + it("returns 400 on invalid body", async () => { + const res = await POST(makeRequest("POST", { role: "viewer" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when the target user doesn't exist", async () => { + mockUserFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(404); + }); + + it("returns 400 when inviting yourself", async () => { + mockUserFindFirst.mockResolvedValue({ id: "user-1" }); + const res = await POST(makeRequest("POST", { email: "a@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(400); + }); + + it("auto-creates the plan for the week and invites the member", async () => { + mockUserFindFirst.mockResolvedValue({ id: "user-2" }); + mockPlanFindFirst.mockResolvedValue(undefined); // no plan yet for this week + mockMemberFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "editor" }), ctx); + expect(res.status).toBe(201); + expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-1", weekStart: "2026-06-01" })); + expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ userId: "user-2", role: "editor" })); + }); + + it("returns 409 when already a member", async () => { + mockUserFindFirst.mockResolvedValue({ id: "user-2" }); + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1", weekStart: "2026-06-01" }); + mockMemberFindFirst.mockResolvedValue({ id: "existing" }); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(409); + }); +}); + +describe("DELETE /api/v1/meal-plans/[weekStart]/members", () => { + it("returns 400 when memberId is missing", async () => { + const res = await DELETE(makeRequest("DELETE"), ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when the owner has no plan for this week", async () => { + mockPlanFindFirst.mockResolvedValue(undefined); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(404); + }); + + it("returns 403 when caller is neither owner nor the member themselves", async () => { + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" }); + mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" }); + vi.mocked(requireSession).mockResolvedValue({ session: { user: { id: "user-3" } } as never, response: null }); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(403); + }); + + it("allows the owner to remove a member", async () => { + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" }); + mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" }); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(204); + }); +}); diff --git a/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts b/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts new file mode 100644 index 0000000..4a638fa --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/[weekStart]/members/route.ts @@ -0,0 +1,122 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, mealPlans, mealPlanMembers, users, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ weekStart: string }> }; + +async function getOrCreatePlan(userId: string, weekStart: string) { + const existing = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, userId), eq(mealPlans.weekStart, weekStart)), + }); + if (existing) return existing; + + const id = crypto.randomUUID(); + await db.insert(mealPlans).values({ id, userId, weekStart }); + return { id, userId, weekStart, createdAt: new Date() }; +} + +// ─── GET /api/v1/meal-plans/[weekStart]/members ────────────────────────────── +// Owner only — returns members joined with basic user info. +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + }); + if (!plan) return NextResponse.json([]); + + const members = await db.query.mealPlanMembers.findMany({ + where: eq(mealPlanMembers.mealPlanId, plan.id), + with: { user: true }, + }); + + return NextResponse.json( + members.map((m) => ({ + id: m.id, + userId: m.userId, + role: m.role, + createdAt: m.createdAt, + user: { name: m.user.name, username: m.user.username, avatarUrl: m.user.avatarUrl }, + })) + ); +} + +// ─── POST /api/v1/meal-plans/[weekStart]/members ───────────────────────────── +// Owner only — invite by email or userId. Auto-creates the plan for this week if missing. +const InviteSchema = z + .object({ + email: z.string().email().optional(), + userId: z.string().optional(), + role: z.enum(["viewer", "editor"]), + }) + .refine((d) => d.email !== undefined || d.userId !== undefined, { + message: "Provide either email or userId", + }); + +export async function POST(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + + const body = await req.json() as unknown; + const parsed = InviteSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const { email, userId, role } = parsed.data; + + const targetUser = await db.query.users.findFirst({ + where: email ? eq(users.email, email) : eq(users.id, userId!), + }); + if (!targetUser) return NextResponse.json({ error: "User not found" }, { status: 404 }); + + if (targetUser.id === session!.user.id) { + return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 }); + } + + const plan = await getOrCreatePlan(session!.user.id, weekStart); + + const existing = await db.query.mealPlanMembers.findFirst({ + where: and(eq(mealPlanMembers.mealPlanId, plan.id), eq(mealPlanMembers.userId, targetUser.id)), + }); + if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 }); + + const memberId = crypto.randomUUID(); + await db.insert(mealPlanMembers).values({ + id: memberId, + mealPlanId: plan.id, + userId: targetUser.id, + role, + }); + + return NextResponse.json({ id: memberId, mealPlanId: plan.id }, { status: 201 }); +} + +// ─── DELETE /api/v1/meal-plans/[weekStart]/members?memberId=… ──────────────── +// Owner OR the member themselves can remove. +export async function DELETE(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { weekStart } = await params; + const memberId = req.nextUrl.searchParams.get("memberId"); + if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 }); + + const plan = await db.query.mealPlans.findFirst({ + where: and(eq(mealPlans.userId, session!.user.id), eq(mealPlans.weekStart, weekStart)), + }); + if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const member = await db.query.mealPlanMembers.findFirst({ + where: and(eq(mealPlanMembers.id, memberId), eq(mealPlanMembers.mealPlanId, plan.id)), + }); + if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const isOwner = plan.userId === session!.user.id; + const isSelf = member.userId === session!.user.id; + if (!isOwner && !isSelf) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + await db.delete(mealPlanMembers).where(eq(mealPlanMembers.id, memberId)); + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/__tests__/route.test.ts b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/__tests__/route.test.ts new file mode 100644 index 0000000..a084bb4 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/__tests__/route.test.ts @@ -0,0 +1,104 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { NextRequest } from "next/server"; + +const mockSession = { user: { id: "user-2" } }; + +vi.mock("@/lib/api-auth", () => ({ + requireSession: vi.fn(), +})); + +vi.mock("@/lib/webhooks", () => ({ + dispatchWebhook: vi.fn(), +})); + +const { mockPlanFindFirst, mockMemberFindFirst, mockRecipeFindFirst, mockInsertValues, mockDeleteWhere } = vi.hoisted(() => ({ + mockPlanFindFirst: vi.fn(), + mockMemberFindFirst: vi.fn(), + mockRecipeFindFirst: vi.fn(), + mockInsertValues: vi.fn().mockResolvedValue(undefined), + mockDeleteWhere: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + mealPlans: { findFirst: mockPlanFindFirst }, + mealPlanMembers: { findFirst: mockMemberFindFirst }, + recipes: { findFirst: mockRecipeFindFirst }, + }, + insert: vi.fn(() => ({ values: mockInsertValues })), + delete: vi.fn(() => ({ where: mockDeleteWhere })), + }, + mealPlans: { id: "id", userId: "user_id" }, + mealPlanMembers: { mealPlanId: "meal_plan_id", userId: "user_id" }, + mealPlanEntries: { id: "id", mealPlanId: "meal_plan_id", day: "day", mealType: "meal_type" }, + recipes: { id: "id", authorId: "author_id", visibility: "visibility" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), + or: vi.fn((...args) => ({ args, op: "or" })), + ne: vi.fn((a, b) => ({ a, b, op: "ne" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +import { POST, DELETE } from "../route"; + +const ctx = { params: Promise.resolve({ mealPlanId: "plan-1" }) }; + +function makeRequest(method: string, body?: unknown, search = "") { + return new NextRequest(`http://localhost/api/v1/meal-plans/shared/plan-1/entries${search}`, { + 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 }); + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" }); +}); + +const validBody = { day: "mon", mealType: "dinner", recipeId: "r-1", servings: 2 }; + +describe("POST /api/v1/meal-plans/shared/[mealPlanId]/entries", () => { + it("returns 404 when the caller has no access to the plan", async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", validBody), ctx); + expect(res.status).toBe(404); + }); + + it("returns 403 for a viewer trying to add an entry", async () => { + mockMemberFindFirst.mockResolvedValue({ role: "viewer" }); + const res = await POST(makeRequest("POST", validBody), ctx); + expect(res.status).toBe(403); + }); + + it("allows an editor to add an entry", async () => { + mockMemberFindFirst.mockResolvedValue({ role: "editor" }); + mockRecipeFindFirst.mockResolvedValue({ id: "r-1" }); + const res = await POST(makeRequest("POST", validBody), ctx); + expect(res.status).toBe(201); + expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ mealPlanId: "plan-1", day: "mon" })); + }); + + it("returns 404 when the recipe isn't accessible to the editor", async () => { + mockMemberFindFirst.mockResolvedValue({ role: "editor" }); + mockRecipeFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", validBody), ctx); + expect(res.status).toBe(404); + }); +}); + +describe("DELETE /api/v1/meal-plans/shared/[mealPlanId]/entries", () => { + it("returns 403 for a viewer trying to remove an entry", async () => { + mockMemberFindFirst.mockResolvedValue({ role: "viewer" }); + const res = await DELETE(makeRequest("DELETE", undefined, "?entryId=e1"), ctx); + expect(res.status).toBe(403); + }); + + it("allows an editor to remove an entry", async () => { + mockMemberFindFirst.mockResolvedValue({ role: "editor" }); + const res = await DELETE(makeRequest("DELETE", undefined, "?entryId=e1"), ctx); + expect(res.status).toBe(204); + }); +}); diff --git a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts new file mode 100644 index 0000000..9318ba4 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/entries/route.ts @@ -0,0 +1,81 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, mealPlanEntries, recipes, eq, and, or, ne } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { getMealPlanAccessById, canWriteMealPlan } from "@/lib/meal-plan-access"; +import { dispatchWebhook } from "@/lib/webhooks"; + +type Params = { params: Promise<{ mealPlanId: string }> }; + +const Schema = z.object({ + day: z.enum(["mon", "tue", "wed", "thu", "fri", "sat", "sun"]), + mealType: z.enum(["breakfast", "lunch", "dinner", "snack"]), + recipeId: z.string().optional(), + servings: z.number().int().min(1).max(100).default(2), + note: z.string().max(500).optional(), +}); + +export async function POST(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { mealPlanId } = await params; + + const access = await getMealPlanAccessById(mealPlanId, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!canWriteMealPlan(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const body = await req.json() as unknown; + const parsed = Schema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + if (parsed.data.recipeId) { + const recipe = await db.query.recipes.findFirst({ + where: and( + eq(recipes.id, parsed.data.recipeId), + or(eq(recipes.authorId, session!.user.id), ne(recipes.visibility, "private")) + ), + }); + if (!recipe) return NextResponse.json({ error: "Recipe not found" }, { status: 404 }); + } + + await db.delete(mealPlanEntries).where( + and( + eq(mealPlanEntries.mealPlanId, mealPlanId), + eq(mealPlanEntries.day, parsed.data.day), + eq(mealPlanEntries.mealType, parsed.data.mealType) + ) + ); + + const entryId = crypto.randomUUID(); + await db.insert(mealPlanEntries).values({ + id: entryId, + mealPlanId, + day: parsed.data.day, + mealType: parsed.data.mealType, + recipeId: parsed.data.recipeId, + servings: parsed.data.servings, + note: parsed.data.note, + }); + + void dispatchWebhook(access.plan.userId, "meal_plan.updated", { mealPlanId, day: parsed.data.day, mealType: parsed.data.mealType }); + return NextResponse.json({ id: entryId }, { status: 201 }); +} + +export async function DELETE(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { mealPlanId } = await params; + + const access = await getMealPlanAccessById(mealPlanId, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!canWriteMealPlan(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + const entryId = req.nextUrl.searchParams.get("entryId"); + if (!entryId) return NextResponse.json({ error: "entryId required" }, { status: 400 }); + + await db.delete(mealPlanEntries).where( + and(eq(mealPlanEntries.id, entryId), eq(mealPlanEntries.mealPlanId, mealPlanId)) + ); + + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/route.ts b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/route.ts new file mode 100644 index 0000000..f211f47 --- /dev/null +++ b/apps/web/app/api/v1/meal-plans/shared/[mealPlanId]/route.ts @@ -0,0 +1,27 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, mealPlans, users, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { getMealPlanAccessById } from "@/lib/meal-plan-access"; + +type Params = { params: Promise<{ mealPlanId: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { mealPlanId } = await params; + + const access = await getMealPlanAccessById(mealPlanId, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const plan = await db.query.mealPlans.findFirst({ + where: eq(mealPlans.id, mealPlanId), + with: { + entries: { with: { recipe: { with: { photos: true } } } }, + }, + }); + if (!plan) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const owner = await db.query.users.findFirst({ where: eq(users.id, plan.userId) }); + + return NextResponse.json({ ...plan, role: access.role, owner: owner ? { name: owner.name } : null }); +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts new file mode 100644 index 0000000..49116d3 --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/export/instacart/route.ts @@ -0,0 +1,33 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, shoppingLists, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { getShoppingListAccess } from "@/lib/shopping-list-access"; +import { buildGroceryExportPayload } from "@/lib/grocery-export"; +import { createInstacartShoppingListLink } from "@/lib/grocery-providers/instacart"; + +type Params = { params: Promise<{ id: string }> }; + +export async function POST(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const list = await db.query.shoppingLists.findFirst({ + where: eq(shoppingLists.id, id), + with: { items: true }, + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const payload = buildGroceryExportPayload(list); + + try { + const result = await createInstacartShoppingListLink(payload); + if (!result) return NextResponse.json({ error: "Instacart is not configured" }, { status: 501 }); + return NextResponse.json(result); + } catch (err) { + return NextResponse.json({ error: String(err instanceof Error ? err.message : err) }, { status: 501 }); + } +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/export/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/export/route.ts new file mode 100644 index 0000000..1245ebf --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/export/route.ts @@ -0,0 +1,25 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db, shoppingLists, eq } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; +import { getShoppingListAccess } from "@/lib/shopping-list-access"; +import { buildGroceryExportPayload } from "@/lib/grocery-export"; + +type Params = { params: Promise<{ id: string }> }; + +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + const { id } = await params; + + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const list = await db.query.shoppingLists.findFirst({ + where: eq(shoppingLists.id, id), + with: { items: true }, + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const payload = buildGroceryExportPayload(list); + return NextResponse.json(payload); +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts index 95157a9..b092c77 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/[itemId]/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db"; +import { db, shoppingListItems, eq, and } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; +import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; type Params = { params: Promise<{ id: string; itemId: string }> }; @@ -9,10 +10,9 @@ export async function PUT(req: NextRequest, { params }: Params) { if (response) return response; const { id, itemId } = await params; - const list = await db.query.shoppingLists.findFirst({ - where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), - }); - if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json() as { checked?: boolean }; await db.update(shoppingListItems) diff --git a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts index 80e0119..75626e0 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/items/route.ts @@ -1,7 +1,8 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db"; +import { db, shoppingListItems } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; +import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; const AddItemsSchema = z.object({ items: z.array(z.object({ @@ -19,10 +20,9 @@ export async function POST(req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; - const list = await db.query.shoppingLists.findFirst({ - where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), - }); - if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = await req.json() as unknown; const parsed = AddItemsSchema.safeParse(body); diff --git a/apps/web/app/api/v1/shopping-lists/[id]/members/__tests__/route.test.ts b/apps/web/app/api/v1/shopping-lists/[id]/members/__tests__/route.test.ts new file mode 100644 index 0000000..c79b789 --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/members/__tests__/route.test.ts @@ -0,0 +1,149 @@ +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 { mockListFindFirst, mockMemberFindFirst, mockMemberFindMany, mockUserFindFirst, mockInsertValues, mockDeleteWhere } = vi.hoisted(() => ({ + mockListFindFirst: vi.fn(), + mockMemberFindFirst: vi.fn(), + mockMemberFindMany: vi.fn(), + mockUserFindFirst: vi.fn(), + mockInsertValues: vi.fn().mockResolvedValue(undefined), + mockDeleteWhere: vi.fn().mockResolvedValue(undefined), +})); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + shoppingLists: { findFirst: mockListFindFirst }, + shoppingListMembers: { findFirst: mockMemberFindFirst, findMany: mockMemberFindMany }, + users: { findFirst: mockUserFindFirst }, + }, + insert: vi.fn(() => ({ values: mockInsertValues })), + delete: vi.fn(() => ({ where: mockDeleteWhere })), + }, + shoppingLists: { id: "id", userId: "user_id" }, + shoppingListMembers: { id: "id", listId: "list_id", userId: "user_id" }, + users: { id: "id", email: "email" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), +})); + +const { requireSession } = await import("@/lib/api-auth"); +import { GET, POST, DELETE } from "../route"; + +const ctx = { params: Promise.resolve({ id: "list-1" }) }; + +function makeRequest(method: string, body?: unknown, search = "") { + return new NextRequest(`http://localhost/api/v1/shopping-lists/list-1/members${search}`, { + 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 }); +}); + +describe("GET /api/v1/shopping-lists/[id]/members", () => { + it("returns 404 when the caller is not the owner", async () => { + mockListFindFirst.mockResolvedValue(undefined); + const res = await GET(makeRequest("GET"), ctx); + expect(res.status).toBe(404); + }); + + it("returns the member list for the owner", async () => { + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" }); + mockMemberFindMany.mockResolvedValue([ + { id: "m1", userId: "user-2", role: "viewer", createdAt: new Date(), user: { name: "Bob", username: "bob", avatarUrl: null } }, + ]); + const res = await GET(makeRequest("GET"), ctx); + expect(res.status).toBe(200); + const body = await res.json() as unknown[]; + expect(body).toHaveLength(1); + }); +}); + +describe("POST /api/v1/shopping-lists/[id]/members", () => { + beforeEach(() => { + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" }); + }); + + it("returns 404 when the caller is not the owner", async () => { + mockListFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(404); + }); + + it("returns 400 on invalid body", async () => { + const res = await POST(makeRequest("POST", { role: "viewer" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when the target user doesn't exist", async () => { + mockUserFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(404); + }); + + it("returns 400 when inviting yourself", async () => { + mockUserFindFirst.mockResolvedValue({ id: "user-1", email: "a@test.com" }); + const res = await POST(makeRequest("POST", { email: "a@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(400); + }); + + it("returns 409 when already a member", async () => { + mockUserFindFirst.mockResolvedValue({ id: "user-2", email: "b@test.com" }); + mockMemberFindFirst.mockResolvedValue({ id: "existing" }); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "viewer" }), ctx); + expect(res.status).toBe(409); + }); + + it("creates the membership on success", async () => { + mockUserFindFirst.mockResolvedValue({ id: "user-2", email: "b@test.com" }); + mockMemberFindFirst.mockResolvedValue(undefined); + const res = await POST(makeRequest("POST", { email: "b@test.com", role: "editor" }), ctx); + expect(res.status).toBe(201); + expect(mockInsertValues).toHaveBeenCalledWith(expect.objectContaining({ listId: "list-1", userId: "user-2", role: "editor" })); + }); +}); + +describe("DELETE /api/v1/shopping-lists/[id]/members", () => { + it("returns 400 when memberId is missing", async () => { + const res = await DELETE(makeRequest("DELETE"), ctx); + expect(res.status).toBe(400); + }); + + it("returns 404 when the member doesn't exist", async () => { + mockMemberFindFirst.mockResolvedValue(undefined); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(404); + }); + + it("returns 403 when caller is neither owner nor the member themselves", async () => { + mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" }); + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-3" }); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(403); + }); + + it("allows the owner to remove a member", async () => { + mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-2" }); + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" }); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(204); + }); + + it("allows a member to remove themselves", async () => { + mockMemberFindFirst.mockResolvedValue({ id: "m1", userId: "user-1" }); + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-3" }); + const res = await DELETE(makeRequest("DELETE", undefined, "?memberId=m1"), ctx); + expect(res.status).toBe(204); + }); +}); diff --git a/apps/web/app/api/v1/shopping-lists/[id]/members/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/members/route.ts new file mode 100644 index 0000000..d7b7319 --- /dev/null +++ b/apps/web/app/api/v1/shopping-lists/[id]/members/route.ts @@ -0,0 +1,127 @@ +import { NextRequest, NextResponse } from "next/server"; +import { z } from "zod"; +import { db, shoppingLists, shoppingListMembers, users, eq, and } from "@epicure/db"; +import { requireSession } from "@/lib/api-auth"; + +type Params = { params: Promise<{ id: string }> }; + +// ─── GET /api/v1/shopping-lists/[id]/members ───────────────────────────────── +// Owner only — returns members joined with basic user info. +export async function GET(_req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const list = await db.query.shoppingLists.findFirst({ + where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const members = await db.query.shoppingListMembers.findMany({ + where: eq(shoppingListMembers.listId, id), + with: { user: true }, + }); + + const result = members.map((m) => ({ + id: m.id, + userId: m.userId, + role: m.role, + createdAt: m.createdAt, + user: { + name: m.user.name, + username: m.user.username, + avatarUrl: m.user.avatarUrl, + }, + })); + + return NextResponse.json(result); +} + +// ─── POST /api/v1/shopping-lists/[id]/members ──────────────────────────────── +// Owner only — invite by email or userId. +const InviteSchema = z + .object({ + email: z.string().email().optional(), + userId: z.string().optional(), + role: z.enum(["viewer", "editor"]), + }) + .refine((d) => d.email !== undefined || d.userId !== undefined, { + message: "Provide either email or userId", + }); + +export async function POST(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + + const list = await db.query.shoppingLists.findFirst({ + where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), + }); + if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const body = await req.json() as unknown; + const parsed = InviteSchema.safeParse(body); + if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); + + const { email, userId, role } = parsed.data; + + const targetUser = await db.query.users.findFirst({ + where: email ? eq(users.email, email) : eq(users.id, userId!), + }); + if (!targetUser) return NextResponse.json({ error: "User not found" }, { status: 404 }); + + if (targetUser.id === session!.user.id) { + return NextResponse.json({ error: "Cannot invite yourself" }, { status: 400 }); + } + + const existing = await db.query.shoppingListMembers.findFirst({ + where: and( + eq(shoppingListMembers.listId, id), + eq(shoppingListMembers.userId, targetUser.id), + ), + }); + if (existing) return NextResponse.json({ error: "Already a member" }, { status: 409 }); + + const memberId = crypto.randomUUID(); + await db.insert(shoppingListMembers).values({ + id: memberId, + listId: id, + userId: targetUser.id, + role, + }); + + return NextResponse.json({ id: memberId }, { status: 201 }); +} + +// ─── DELETE /api/v1/shopping-lists/[id]/members?memberId=… ─────────────────── +// Owner OR the member themselves can remove. +export async function DELETE(req: NextRequest, { params }: Params) { + const { session, response } = await requireSession(); + if (response) return response; + + const { id } = await params; + const memberId = req.nextUrl.searchParams.get("memberId"); + if (!memberId) return NextResponse.json({ error: "memberId required" }, { status: 400 }); + + const member = await db.query.shoppingListMembers.findFirst({ + where: and(eq(shoppingListMembers.id, memberId), eq(shoppingListMembers.listId, id)), + }); + if (!member) return NextResponse.json({ error: "Not found" }, { status: 404 }); + + const list = await db.query.shoppingLists.findFirst({ + where: eq(shoppingLists.id, id), + }); + + const isOwner = list?.userId === session!.user.id; + const isSelf = member.userId === session!.user.id; + + if (!isOwner && !isSelf) { + return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + } + + await db.delete(shoppingListMembers).where(eq(shoppingListMembers.id, memberId)); + + return new NextResponse(null, { status: 204 }); +} diff --git a/apps/web/app/api/v1/shopping-lists/[id]/route.ts b/apps/web/app/api/v1/shopping-lists/[id]/route.ts index e643dbf..6455159 100644 --- a/apps/web/app/api/v1/shopping-lists/[id]/route.ts +++ b/apps/web/app/api/v1/shopping-lists/[id]/route.ts @@ -1,8 +1,9 @@ import { NextRequest, NextResponse } from "next/server"; import { z } from "zod"; -import { db, shoppingLists, shoppingListItems, eq, and } from "@epicure/db"; +import { db, shoppingLists, shoppingListItems, eq } from "@epicure/db"; import { requireSession } from "@/lib/api-auth"; import { dispatchWebhook } from "@/lib/webhooks"; +import { getShoppingListAccess, canWriteShoppingList } from "@/lib/shopping-list-access"; type Params = { params: Promise<{ id: string }> }; @@ -11,12 +12,14 @@ export async function GET(_req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + const list = await db.query.shoppingLists.findFirst({ - where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), + where: eq(shoppingLists.id, id), with: { items: { orderBy: (t, { asc }) => [asc(t.aisle), asc(t.rawName)] } }, }); - if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); return NextResponse.json(list); } @@ -27,10 +30,9 @@ export async function PATCH(req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; - const list = await db.query.shoppingLists.findFirst({ - where: and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id)), - }); - if (!list) return NextResponse.json({ error: "Not found" }, { status: 404 }); + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (!canWriteShoppingList(access.role)) return NextResponse.json({ error: "Forbidden" }, { status: 403 }); const body = PatchSchema.safeParse(await req.json()); if (!body.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); @@ -38,7 +40,7 @@ export async function PATCH(req: NextRequest, { params }: Params) { if (body.data.completed) { // Mark all items as checked await db.update(shoppingListItems).set({ checked: true }).where(eq(shoppingListItems.listId, id)); - void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: list.name }); + void dispatchWebhook(session!.user.id, "shopping_list.completed", { id, name: access.list.name }); } return NextResponse.json({ updated: true }); @@ -49,6 +51,10 @@ export async function DELETE(_req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; - await db.delete(shoppingLists).where(and(eq(shoppingLists.id, id), eq(shoppingLists.userId, session!.user.id))); + const access = await getShoppingListAccess(id, session!.user.id); + if (!access) return NextResponse.json({ error: "Not found" }, { status: 404 }); + if (access.role !== "owner") return NextResponse.json({ error: "Forbidden" }, { status: 403 }); + + await db.delete(shoppingLists).where(eq(shoppingLists.id, id)); return new NextResponse(null, { status: 204 }); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index ff86d0f..fcdb168 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import { Lora, Geist_Mono } from "next/font/google"; import { headers } from "next/headers"; import { Providers } from "@/components/providers"; @@ -21,6 +21,11 @@ const geistMono = Geist_Mono({ export const metadata: Metadata = { title: { default: "Epicure", template: "%s | Epicure" }, description: "Your personal AI-powered recipe book.", + manifest: "/manifest.json", +}; + +export const viewport: Viewport = { + themeColor: "#18181b", }; export default async function RootLayout({ diff --git a/apps/web/app/print/collection/[id]/page.tsx b/apps/web/app/print/collection/[id]/page.tsx new file mode 100644 index 0000000..d437ae2 --- /dev/null +++ b/apps/web/app/print/collection/[id]/page.tsx @@ -0,0 +1,148 @@ +import { notFound } from "next/navigation"; +import { headers } from "next/headers"; +import { auth } from "@/lib/auth/server"; +import { db, collections, eq, and, or } from "@epicure/db"; +import { PrintTrigger } from "@/components/recipe/print-trigger"; +import { hasQuantity } from "@/lib/fractions"; +import { getMessages, formatMessage } from "@/lib/i18n/server"; + +type Params = { params: Promise<{ id: string }> }; + +export default async function CollectionPrintPage({ params }: Params) { + const { id } = await params; + const session = await auth.api.getSession({ headers: await headers() }); + if (!session) return null; + + const m = getMessages((session.user as { locale?: string }).locale); + + const col = await db.query.collections.findFirst({ + where: and( + eq(collections.id, id), + or(eq(collections.userId, session.user.id), eq(collections.isPublic, true)) + ), + with: { + recipes: { + with: { + recipe: { + with: { + ingredients: { orderBy: (t, { asc }) => asc(t.order) }, + steps: { orderBy: (t, { asc }) => asc(t.order) }, + }, + }, + }, + }, + }, + }); + + if (!col) notFound(); + + const recipeEntries = col.recipes.filter((r) => r.recipe !== null); + + return ( + <> + + + + +
+

{col.name}

+ {col.description &&

{col.description}

} +

+ {recipeEntries.length} recipe{recipeEntries.length !== 1 ? "s" : ""} +

+
+ + {recipeEntries.map(({ recipe }) => { + if (!recipe) return null; + const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0); + return ( +
+

{recipe.title}

+ + {recipe.description &&

{recipe.description}

} + +
+ {recipe.baseServings && {formatMessage(m.recipe.servings, { count: recipe.baseServings })}} + {recipe.prepMins && {formatMessage(m.recipe.prep, { mins: recipe.prepMins })}} + {recipe.cookMins && {formatMessage(m.recipe.cook, { mins: recipe.cookMins })}} + {totalMins > 0 && {formatMessage(m.recipe.total, { mins: totalMins })}} + {recipe.difficulty && {recipe.difficulty.charAt(0).toUpperCase() + recipe.difficulty.slice(1)}} +
+ + {recipe.ingredients.length > 0 && ( + <> +

{m.recipe.ingredients}

+
    + {recipe.ingredients.map((ing) => ( +
  • + + {[hasQuantity(ing.quantity) ? ing.quantity : null, ing.unit].filter(Boolean).join(" ")} + + {ing.rawName} + {ing.note && ({ing.note})} +
  • + ))} +
+ + )} + + {recipe.steps.length > 0 && ( + <> +

{m.recipe.instructions}

+
    + {recipe.steps.map((step) => ( +
  1. + {step.instruction} + {step.timerSeconds && ( + ⏱ {Math.floor(step.timerSeconds / 60)} min + )} +
  2. + ))} +
+ + )} +
+ ); + })} + +
{m.print.footer}
+ + ); +} diff --git a/apps/web/components/collections/collections-page-content.tsx b/apps/web/components/collections/collections-page-content.tsx index b81398b..09c7dda 100644 --- a/apps/web/components/collections/collections-page-content.tsx +++ b/apps/web/components/collections/collections-page-content.tsx @@ -22,7 +22,7 @@ export function CollectionsPageContent({ collections }: Props) { return (
-
+

{t("title")}

{t("subtitle")}

diff --git a/apps/web/components/feed/feed-page-content.tsx b/apps/web/components/feed/feed-page-content.tsx index e9f2580..b3251d9 100644 --- a/apps/web/components/feed/feed-page-content.tsx +++ b/apps/web/components/feed/feed-page-content.tsx @@ -2,7 +2,7 @@ import { useState, useEffect } from "react"; import { useTranslations } from "next-intl"; import Link from "next/link"; -import { Clock, Users, ChefHat, Flame, Heart } from "lucide-react"; +import { Clock, Users, ChefHat, Flame, Heart, Sparkles } from "lucide-react"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; import { useLocale } from "@/lib/i18n/provider"; @@ -99,10 +99,36 @@ function TrendingTab() { ); } +function ForYouTab() { + const { locale } = useLocale(); + const t = useTranslations("feed"); + const [recipes, setRecipes] = useState([]); + const [loading, setLoading] = useState(true); + + useEffect(() => { + fetch("/api/v1/feed/for-you") + .then((r) => r.json() as Promise<{ data: FeedRecipe[] }>) + .then(({ data }) => setRecipes(data)) + .catch(() => {}) + .finally(() => setLoading(false)); + }, []); + + if (loading) return

{t("loading")}

; + if (recipes.length === 0) return

{t("forYouEmpty")}

; + + return ( +
+ {recipes.map((recipe) => ( + + ))} +
+ ); +} + export function FeedPageContent({ followedCount, feedRecipes }: Props) { const t = useTranslations("feed"); const { locale } = useLocale(); - const [tab, setTab] = useState<"following" | "trending">("following"); + const [tab, setTab] = useState<"following" | "trending" | "forYou">("following"); return (
@@ -131,6 +157,17 @@ export function FeedPageContent({ followedCount, feedRecipes }: Props) { {t("trending")} +
{tab === "following" ? ( @@ -147,8 +184,10 @@ export function FeedPageContent({ followedCount, feedRecipes }: Props) { ))}
) - ) : ( + ) : tab === "trending" ? ( + ) : ( + )}
); diff --git a/apps/web/components/meal-plan/share-meal-plan-button.tsx b/apps/web/components/meal-plan/share-meal-plan-button.tsx new file mode 100644 index 0000000..4ef32dc --- /dev/null +++ b/apps/web/components/meal-plan/share-meal-plan-button.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus, X } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +type Role = "viewer" | "editor"; + +interface Member { + id: string; + userId: string; + role: Role; + createdAt: string; + user: { + name: string; + username: string | null; + avatarUrl: string | null; + }; +} + +interface Props { + weekStart: string; +} + +export function ShareMealPlanButton({ weekStart }: Props) { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("viewer"); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(false); + const [inviting, setInviting] = useState(false); + + async function fetchMembers() { + setLoading(true); + try { + const res = await fetch(`/api/v1/meal-plans/${weekStart}/members`); + if (!res.ok) throw new Error("Failed to load members"); + const data = await res.json() as Member[]; + setMembers(data); + } catch { + toast.error("Could not load members"); + } finally { + setLoading(false); + } + } + + function handleOpenChange(next: boolean) { + setOpen(next); + if (next) { + void fetchMembers(); + } else { + setEmail(""); + setRole("viewer"); + } + } + + async function handleInvite() { + if (!email.trim()) { + toast.error("Enter an email address"); + return; + } + setInviting(true); + try { + const res = await fetch(`/api/v1/meal-plans/${weekStart}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim(), role }), + }); + if (res.status === 409) { toast.error("Already a member"); return; } + if (res.status === 404) { toast.error("User not found"); return; } + if (!res.ok) { toast.error("Could not invite user"); return; } + toast.success("Invitation sent"); + setEmail(""); + await fetchMembers(); + } catch { + toast.error("Could not invite user"); + } finally { + setInviting(false); + } + } + + async function handleRemove(memberId: string) { + try { + const res = await fetch( + `/api/v1/meal-plans/${weekStart}/members?memberId=${memberId}`, + { method: "DELETE" }, + ); + if (!res.ok) { toast.error("Could not remove member"); return; } + setMembers((prev) => prev.filter((m) => m.id !== memberId)); + toast.success("Member removed"); + } catch { + toast.error("Could not remove member"); + } + } + + return ( + <> + + + + + + Share this week's plan + + Invite household members to view or edit this week's meal plan. + + + +
+ setEmail(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }} + className="flex-1" + /> + + +
+ +
+ {loading && ( +

Loading members…

+ )} + {!loading && members.length === 0 && ( +

No members yet.

+ )} + {members.map((m) => ( +
+
+ {m.user.name} + {m.user.username && ( + + @{m.user.username} + + )} +
+ + {m.role} + + +
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/components/meal-plan/shared-meal-plan-view.tsx b/apps/web/components/meal-plan/shared-meal-plan-view.tsx new file mode 100644 index 0000000..9082ae5 --- /dev/null +++ b/apps/web/components/meal-plan/shared-meal-plan-view.tsx @@ -0,0 +1,128 @@ +"use client"; + +import { useState } from "react"; +import { toast } from "sonner"; +import { Trash2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; + +type Day = "mon" | "tue" | "wed" | "thu" | "fri" | "sat" | "sun"; +type MealType = "breakfast" | "lunch" | "dinner" | "snack"; + +const DAYS: Day[] = ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]; +const MEAL_TYPES: MealType[] = ["breakfast", "lunch", "dinner", "snack"]; + +type Entry = { + id: string; + day: Day; + mealType: MealType; + servings: number; + recipe: { id: string; title: string } | null; +}; + +type UserRecipe = { id: string; title: string }; + +export function SharedMealPlanView({ + mealPlanId, + initialEntries, + userRecipes, + canEdit, +}: { + mealPlanId: string; + initialEntries: Entry[]; + userRecipes: UserRecipe[]; + canEdit: boolean; +}) { + const [entries, setEntries] = useState(initialEntries); + const [addingCell, setAddingCell] = useState(null); + + function cellKey(day: Day, mealType: MealType) { + return `${day}-${mealType}`; + } + + async function addEntry(day: Day, mealType: MealType, recipeId: string) { + const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ day, mealType, recipeId, servings: 2 }), + }); + if (!res.ok) { toast.error("Could not add recipe"); return; } + const { id } = await res.json() as { id: string }; + const recipe = userRecipes.find((r) => r.id === recipeId) ?? null; + setEntries((prev) => [ + ...prev.filter((e) => !(e.day === day && e.mealType === mealType)), + { id, day, mealType, servings: 2, recipe }, + ]); + setAddingCell(null); + } + + async function removeEntry(entry: Entry) { + const res = await fetch(`/api/v1/meal-plans/shared/${mealPlanId}/entries?entryId=${entry.id}`, { + method: "DELETE", + }); + if (!res.ok) { toast.error("Could not remove entry"); return; } + setEntries((prev) => prev.filter((e) => e.id !== entry.id)); + } + + return ( +
+ + + + + {DAYS.map((day) => ( + + ))} + + + + {MEAL_TYPES.map((mealType) => ( + + + {DAYS.map((day) => { + const entry = entries.find((e) => e.day === day && e.mealType === mealType); + const key = cellKey(day, mealType); + return ( + + ); + })} + + ))} + +
{day}
{mealType} + {entry ? ( +
+ {entry.recipe?.title ?? "—"} + {canEdit && ( + + )} +
+ ) : canEdit ? ( + addingCell === key ? ( + + ) : ( + + ) + ) : ( + + )} +
+
+ ); +} diff --git a/apps/web/components/meal-plan/shopping-list-view.tsx b/apps/web/components/meal-plan/shopping-list-view.tsx index 9d94836..e1470dd 100644 --- a/apps/web/components/meal-plan/shopping-list-view.tsx +++ b/apps/web/components/meal-plan/shopping-list-view.tsx @@ -20,9 +20,11 @@ type Item = { export function ShoppingListView({ listId, initialItems, + readOnly = false, }: { listId: string; initialItems: Item[]; + readOnly?: boolean; }) { const t = useTranslations("mealPlan"); const tShopping = useTranslations("shoppingLists"); @@ -54,6 +56,7 @@ export function ShoppingListView({ } async function toggleItem(item: Item) { + if (readOnly) return; const next = !item.checked; setItems((prev) => prev.map((i) => i.id === item.id ? { ...i, checked: next } : i)); await fetch(`/api/v1/shopping-lists/${listId}/items/${item.id}`, { @@ -97,7 +100,8 @@ export function ShoppingListView({ +
@@ -181,6 +216,19 @@ export function VersionHistoryButton({ recipeId }: { recipeId: string }) {
+ + !isOpen && setComparingId(null)}> + + + + {comparingId ? `Compare v${expandedData[comparingId]?.version} with current` : "Compare"} + + + {comparingId && expandedData[comparingId] && ( + + )} + + ); } diff --git a/apps/web/components/shopping-lists/grocery-export-button.tsx b/apps/web/components/shopping-lists/grocery-export-button.tsx new file mode 100644 index 0000000..2030a35 --- /dev/null +++ b/apps/web/components/shopping-lists/grocery-export-button.tsx @@ -0,0 +1,83 @@ +"use client"; + +import { useState } from "react"; +import { ShoppingBag, Copy, ExternalLink } from "lucide-react"; +import { toast } from "sonner"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Button } from "@/components/ui/button"; +import type { GroceryExportPayload } from "@/lib/grocery-export"; +import { groceryExportToText } from "@/lib/grocery-export"; + +interface Props { + listId: string; + /** Set when NEXT_PUBLIC_GROCERY_PROVIDER=instacart — otherwise only "copy as text" is offered. */ + instacartEnabled: boolean; +} + +export function GroceryExportButton({ listId, instacartEnabled }: Props) { + const [loading, setLoading] = useState(false); + + async function fetchPayload(): Promise { + const res = await fetch(`/api/v1/shopping-lists/${listId}/export`); + if (!res.ok) { + toast.error("Could not build export"); + return null; + } + return res.json() as Promise; + } + + async function handleCopy() { + setLoading(true); + try { + const payload = await fetchPayload(); + if (!payload) return; + await navigator.clipboard.writeText(groceryExportToText(payload)); + toast.success("List copied to clipboard"); + } finally { + setLoading(false); + } + } + + async function handleInstacart() { + setLoading(true); + try { + const res = await fetch(`/api/v1/shopping-lists/${listId}/export/instacart`, { method: "POST" }); + if (!res.ok) { + toast.error("Instacart isn't configured yet"); + return; + } + const { url } = await res.json() as { url: string }; + window.open(url, "_blank"); + } finally { + setLoading(false); + } + } + + return ( + + + + Send to grocery delivery + + } /> + + void handleCopy()}> + + Copy list as text + + {instacartEnabled && ( + void handleInstacart()}> + + Send to Instacart + + )} + + + ); +} diff --git a/apps/web/components/shopping-lists/share-shopping-list-button.tsx b/apps/web/components/shopping-lists/share-shopping-list-button.tsx new file mode 100644 index 0000000..60b9ee3 --- /dev/null +++ b/apps/web/components/shopping-lists/share-shopping-list-button.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState } from "react"; +import { UserPlus, X } from "lucide-react"; +import { toast } from "sonner"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { Badge } from "@/components/ui/badge"; + +type Role = "viewer" | "editor"; + +interface Member { + id: string; + userId: string; + role: Role; + createdAt: string; + user: { + name: string; + username: string | null; + avatarUrl: string | null; + }; +} + +interface Props { + listId: string; +} + +export function ShareShoppingListButton({ listId }: Props) { + const [open, setOpen] = useState(false); + const [email, setEmail] = useState(""); + const [role, setRole] = useState("viewer"); + const [members, setMembers] = useState([]); + const [loading, setLoading] = useState(false); + const [inviting, setInviting] = useState(false); + + async function fetchMembers() { + setLoading(true); + try { + const res = await fetch(`/api/v1/shopping-lists/${listId}/members`); + if (!res.ok) throw new Error("Failed to load members"); + const data = await res.json() as Member[]; + setMembers(data); + } catch { + toast.error("Could not load members"); + } finally { + setLoading(false); + } + } + + function handleOpenChange(next: boolean) { + setOpen(next); + if (next) { + void fetchMembers(); + } else { + setEmail(""); + setRole("viewer"); + } + } + + async function handleInvite() { + if (!email.trim()) { + toast.error("Enter an email address"); + return; + } + setInviting(true); + try { + const res = await fetch(`/api/v1/shopping-lists/${listId}/members`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ email: email.trim(), role }), + }); + if (res.status === 409) { toast.error("Already a member"); return; } + if (res.status === 404) { toast.error("User not found"); return; } + if (!res.ok) { toast.error("Could not invite user"); return; } + toast.success("Invitation sent"); + setEmail(""); + await fetchMembers(); + } catch { + toast.error("Could not invite user"); + } finally { + setInviting(false); + } + } + + async function handleRemove(memberId: string) { + try { + const res = await fetch( + `/api/v1/shopping-lists/${listId}/members?memberId=${memberId}`, + { method: "DELETE" }, + ); + if (!res.ok) { toast.error("Could not remove member"); return; } + setMembers((prev) => prev.filter((m) => m.id !== memberId)); + toast.success("Member removed"); + } catch { + toast.error("Could not remove member"); + } + } + + return ( + <> + + + + + + Share shopping list + + Invite household members to view or edit this list. + + + +
+ setEmail(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") void handleInvite(); }} + className="flex-1" + /> + + +
+ +
+ {loading && ( +

Loading members…

+ )} + {!loading && members.length === 0 && ( +

No members yet.

+ )} + {members.map((m) => ( +
+
+ {m.user.name} + {m.user.username && ( + + @{m.user.username} + + )} +
+ + {m.role} + + +
+ ))} +
+
+
+ + ); +} diff --git a/apps/web/components/shopping-lists/shopping-lists-page-content.tsx b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx index 04d42af..13c9ada 100644 --- a/apps/web/components/shopping-lists/shopping-lists-page-content.tsx +++ b/apps/web/components/shopping-lists/shopping-lists-page-content.tsx @@ -12,16 +12,24 @@ type ShoppingListItem = { checkedItems: number; }; -type Props = { - lists: ShoppingListItem[]; +type SharedListItem = { + id: string; + name: string; + ownerName: string; + role: "viewer" | "editor"; }; -export function ShoppingListsPageContent({ lists }: Props) { +type Props = { + lists: ShoppingListItem[]; + sharedLists?: SharedListItem[]; +}; + +export function ShoppingListsPageContent({ lists, sharedLists = [] }: Props) { const t = useTranslations("shoppingLists"); return (
-
+

{t("title")}

{t("subtitle")}

@@ -58,6 +66,26 @@ export function ShoppingListsPageContent({ lists }: Props) { ))}
)} + + {sharedLists.length > 0 && ( +
+

Shared with you

+ {sharedLists.map((list) => ( + +
+

{list.name}

+

+ {list.ownerName} · {list.role} +

+
+ + ))} +
+ )}
); } diff --git a/apps/web/lib/__tests__/for-you-ranking.test.ts b/apps/web/lib/__tests__/for-you-ranking.test.ts new file mode 100644 index 0000000..4fba60e --- /dev/null +++ b/apps/web/lib/__tests__/for-you-ranking.test.ts @@ -0,0 +1,56 @@ +import { describe, it, expect } from "vitest"; +import { buildPreferenceMap, scoreCandidate, rankForYou } from "../for-you-ranking"; + +describe("buildPreferenceMap", () => { + it("counts tags and true dietary-tag keys across liked recipes", () => { + const map = buildPreferenceMap([ + { tags: ["spicy", "quick"], dietaryTags: { vegan: true, glutenFree: false } }, + { tags: ["spicy"], dietaryTags: null }, + ]); + expect(map.get("spicy")).toBe(2); + expect(map.get("quick")).toBe(1); + expect(map.get("vegan")).toBe(1); + expect(map.get("glutenFree")).toBeUndefined(); + }); + + it("returns an empty map for no liked recipes", () => { + expect(buildPreferenceMap([]).size).toBe(0); + }); +}); + +describe("scoreCandidate", () => { + it("sums preference weights for overlapping tags", () => { + const prefs = new Map([["spicy", 3], ["vegan", 1]]); + const score = scoreCandidate({ tags: ["spicy"], dietaryTags: { vegan: true } }, prefs); + expect(score).toBe(4); + }); + + it("scores 0 when nothing overlaps", () => { + const prefs = new Map([["spicy", 3]]); + expect(scoreCandidate({ tags: ["sweet"], dietaryTags: null }, prefs)).toBe(0); + }); +}); + +describe("rankForYou", () => { + const base = { dietaryTags: null }; + + it("sorts by score descending", () => { + const prefs = new Map([["spicy", 5]]); + const candidates = [ + { id: "a", tags: ["sweet"], createdAt: new Date("2024-01-01"), ...base }, + { id: "b", tags: ["spicy"], createdAt: new Date("2024-01-01"), ...base }, + ]; + const ranked = rankForYou(candidates, prefs); + expect(ranked.map((r) => r.id)).toEqual(["b", "a"]); + }); + + it("breaks ties by recency", () => { + const prefs = new Map(); + const candidates = [ + { id: "old", tags: [], createdAt: new Date("2024-01-01"), ...base }, + { id: "new", tags: [], createdAt: new Date("2024-06-01"), ...base }, + ]; + const ranked = rankForYou(candidates, prefs); + expect(ranked.map((r) => r.id)).toEqual(["new", "old"]); + }); +}); diff --git a/apps/web/lib/__tests__/grocery-export.test.ts b/apps/web/lib/__tests__/grocery-export.test.ts new file mode 100644 index 0000000..c66043c --- /dev/null +++ b/apps/web/lib/__tests__/grocery-export.test.ts @@ -0,0 +1,29 @@ +import { describe, it, expect } from "vitest"; +import { buildGroceryExportPayload, groceryExportToText } from "../grocery-export"; + +describe("buildGroceryExportPayload", () => { + it("maps unchecked items and drops checked ones", () => { + const payload = buildGroceryExportPayload({ + name: "Weekly groceries", + items: [ + { rawName: "Milk", quantity: "1", unit: "L", checked: false }, + { rawName: "Eggs", quantity: "12", unit: null, checked: true }, + ], + }); + expect(payload.listName).toBe("Weekly groceries"); + expect(payload.items).toEqual([{ name: "Milk", quantity: "1", unit: "L" }]); + }); +}); + +describe("groceryExportToText", () => { + it("renders a plain-text list with quantities", () => { + const text = groceryExportToText({ + listName: "Weekly groceries", + items: [ + { name: "Milk", quantity: "1", unit: "L" }, + { name: "Bananas", quantity: null, unit: null }, + ], + }); + expect(text).toBe("Weekly groceries\n\n1 L Milk\nBananas"); + }); +}); diff --git a/apps/web/lib/__tests__/meal-plan-access.test.ts b/apps/web/lib/__tests__/meal-plan-access.test.ts new file mode 100644 index 0000000..e960257 --- /dev/null +++ b/apps/web/lib/__tests__/meal-plan-access.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockPlanFindFirst, mockMemberFindFirst } = vi.hoisted(() => ({ + mockPlanFindFirst: vi.fn(), + mockMemberFindFirst: vi.fn(), +})); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + mealPlans: { findFirst: mockPlanFindFirst }, + mealPlanMembers: { findFirst: mockMemberFindFirst }, + }, + }, + mealPlans: { id: "id", userId: "user_id" }, + mealPlanMembers: { mealPlanId: "meal_plan_id", userId: "user_id" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), +})); + +const { getMealPlanAccessById, canWriteMealPlan } = await import("../meal-plan-access"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getMealPlanAccessById", () => { + it("returns null when the plan doesn't exist", async () => { + mockPlanFindFirst.mockResolvedValue(undefined); + expect(await getMealPlanAccessById("plan-1", "user-1")).toBeNull(); + }); + + it("grants owner role to the plan's userId", async () => { + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-1" }); + const access = await getMealPlanAccessById("plan-1", "user-1"); + expect(access?.role).toBe("owner"); + }); + + it("grants the member's assigned role", async () => { + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-owner" }); + mockMemberFindFirst.mockResolvedValue({ role: "viewer" }); + const access = await getMealPlanAccessById("plan-1", "user-2"); + expect(access?.role).toBe("viewer"); + }); + + it("returns null when the user is neither owner nor a member", async () => { + mockPlanFindFirst.mockResolvedValue({ id: "plan-1", userId: "user-owner" }); + mockMemberFindFirst.mockResolvedValue(undefined); + expect(await getMealPlanAccessById("plan-1", "user-2")).toBeNull(); + }); +}); + +describe("canWriteMealPlan", () => { + it("allows owner and editor, denies viewer", () => { + expect(canWriteMealPlan("owner")).toBe(true); + expect(canWriteMealPlan("editor")).toBe(true); + expect(canWriteMealPlan("viewer")).toBe(false); + }); +}); diff --git a/apps/web/lib/__tests__/shopping-list-access.test.ts b/apps/web/lib/__tests__/shopping-list-access.test.ts new file mode 100644 index 0000000..407ee9f --- /dev/null +++ b/apps/web/lib/__tests__/shopping-list-access.test.ts @@ -0,0 +1,59 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; + +const { mockListFindFirst, mockMemberFindFirst } = vi.hoisted(() => ({ + mockListFindFirst: vi.fn(), + mockMemberFindFirst: vi.fn(), +})); + +vi.mock("@epicure/db", () => ({ + db: { + query: { + shoppingLists: { findFirst: mockListFindFirst }, + shoppingListMembers: { findFirst: mockMemberFindFirst }, + }, + }, + shoppingLists: { id: "id", userId: "user_id" }, + shoppingListMembers: { listId: "list_id", userId: "user_id" }, + eq: vi.fn((a, b) => ({ a, b, op: "eq" })), + and: vi.fn((...args) => ({ args, op: "and" })), +})); + +const { getShoppingListAccess, canWriteShoppingList } = await import("../shopping-list-access"); + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe("getShoppingListAccess", () => { + it("returns null when the list doesn't exist", async () => { + mockListFindFirst.mockResolvedValue(undefined); + expect(await getShoppingListAccess("list-1", "user-1")).toBeNull(); + }); + + it("grants owner role to the list's userId", async () => { + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-1" }); + const access = await getShoppingListAccess("list-1", "user-1"); + expect(access?.role).toBe("owner"); + }); + + it("grants the member's assigned role", async () => { + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner" }); + mockMemberFindFirst.mockResolvedValue({ role: "editor" }); + const access = await getShoppingListAccess("list-1", "user-2"); + expect(access?.role).toBe("editor"); + }); + + it("returns null when the user is neither owner nor a member", async () => { + mockListFindFirst.mockResolvedValue({ id: "list-1", userId: "user-owner" }); + mockMemberFindFirst.mockResolvedValue(undefined); + expect(await getShoppingListAccess("list-1", "user-2")).toBeNull(); + }); +}); + +describe("canWriteShoppingList", () => { + it("allows owner and editor, denies viewer", () => { + expect(canWriteShoppingList("owner")).toBe(true); + expect(canWriteShoppingList("editor")).toBe(true); + expect(canWriteShoppingList("viewer")).toBe(false); + }); +}); diff --git a/apps/web/lib/for-you-ranking.ts b/apps/web/lib/for-you-ranking.ts new file mode 100644 index 0000000..efadfa4 --- /dev/null +++ b/apps/web/lib/for-you-ranking.ts @@ -0,0 +1,43 @@ +export type TaggedRecipe = { + id: string; + tags: string[]; + dietaryTags: Record | null; + createdAt: Date; +}; + +/** Collapses a recipe's tags + true dietary-tag keys into one flat tag list. */ +function tagSet(recipe: Pick): string[] { + const dietary = Object.entries(recipe.dietaryTags ?? {}) + .filter(([, v]) => v) + .map(([k]) => k); + return [...recipe.tags, ...dietary]; +} + +/** Builds a tag → frequency map from the recipes a user has favorited/highly rated. */ +export function buildPreferenceMap(likedRecipes: Array>): Map { + const map = new Map(); + for (const recipe of likedRecipes) { + for (const tag of tagSet(recipe)) { + map.set(tag, (map.get(tag) ?? 0) + 1); + } + } + return map; +} + +/** + * Scores a candidate recipe by how many of its tags overlap with the user's + * preference map (weighted by how often that tag shows up in their history). + * Recency is used only as a tiebreaker (see rankForYou) — an empty preference + * map scores everything 0, which the caller should treat as "fall back to + * trending" rather than a meaningful ranking. + */ +export function scoreCandidate(candidate: Pick, preferences: Map): number { + return tagSet(candidate).reduce((sum, tag) => sum + (preferences.get(tag) ?? 0), 0); +} + +export function rankForYou(candidates: T[], preferences: Map): T[] { + return [...candidates] + .map((c) => ({ c, score: scoreCandidate(c, preferences) })) + .sort((a, b) => b.score - a.score || b.c.createdAt.getTime() - a.c.createdAt.getTime()) + .map(({ c }) => c); +} diff --git a/apps/web/lib/grocery-export.ts b/apps/web/lib/grocery-export.ts new file mode 100644 index 0000000..7e1f2d5 --- /dev/null +++ b/apps/web/lib/grocery-export.ts @@ -0,0 +1,33 @@ +export type GroceryExportItem = { + name: string; + quantity: string | null; + unit: string | null; +}; + +export type GroceryExportPayload = { + listName: string; + items: GroceryExportItem[]; +}; + +type ShoppingListForExport = { + name: string; + items: Array<{ rawName: string; quantity: string | null; unit: string | null; checked: boolean }>; +}; + +/** Maps a shopping list into a provider-agnostic export shape any grocery-delivery adapter can consume. */ +export function buildGroceryExportPayload(list: ShoppingListForExport): GroceryExportPayload { + return { + listName: list.name, + items: list.items + .filter((i) => !i.checked) + .map((i) => ({ name: i.rawName, quantity: i.quantity, unit: i.unit })), + }; +} + +export function groceryExportToText(payload: GroceryExportPayload): string { + const lines = payload.items.map((i) => { + const qty = [i.quantity, i.unit].filter(Boolean).join(" "); + return qty ? `${qty} ${i.name}` : i.name; + }); + return [payload.listName, "", ...lines].join("\n"); +} diff --git a/apps/web/lib/grocery-providers/instacart.ts b/apps/web/lib/grocery-providers/instacart.ts new file mode 100644 index 0000000..6eecd91 --- /dev/null +++ b/apps/web/lib/grocery-providers/instacart.ts @@ -0,0 +1,27 @@ +import type { GroceryExportPayload } from "@/lib/grocery-export"; + +/** + * Stub adapter for the Instacart "Recipe & Shopping List" partner API. + * + * Not wired to a live endpoint — Instacart requires a signed partnership + * agreement and a per-integration API key before any request can succeed. + * This documents the request shape so activation is a config change, not a + * rewrite, once `INSTACART_API_KEY` is issued. + * + * Real endpoint (per Instacart Developer Platform docs, subject to change): + * POST https://connect.instacart.com/idp/v1/products/products_link + * Authorization: Bearer + * Body: { title: string, link_type: "shopping_list", line_items: [{ name, quantity, unit }] } + * Response contains a `products_link_url` the user is redirected to. + */ +export async function createInstacartShoppingListLink( + payload: GroceryExportPayload +): Promise<{ url: string } | null> { + const apiKey = process.env["INSTACART_API_KEY"]; + if (!apiKey) return null; + + throw new Error( + "Instacart integration is stubbed — INSTACART_API_KEY is set but no live API call is wired up yet. " + + `Would send list "${payload.listName}" with ${payload.items.length} item(s).` + ); +} diff --git a/apps/web/lib/meal-plan-access.ts b/apps/web/lib/meal-plan-access.ts new file mode 100644 index 0000000..ac3d456 --- /dev/null +++ b/apps/web/lib/meal-plan-access.ts @@ -0,0 +1,29 @@ +import { db, mealPlans, mealPlanMembers, eq, and } from "@epicure/db"; + +export type MealPlanRole = "owner" | "editor" | "viewer"; + +export type MealPlanAccess = { + plan: typeof mealPlans.$inferSelect; + role: MealPlanRole; +}; + +/** Resolves a user's access to a meal plan by its id — owner, or member with their assigned role. Null if no access. */ +export async function getMealPlanAccessById( + mealPlanId: string, + userId: string +): Promise { + const plan = await db.query.mealPlans.findFirst({ where: eq(mealPlans.id, mealPlanId) }); + if (!plan) return null; + if (plan.userId === userId) return { plan, role: "owner" }; + + const member = await db.query.mealPlanMembers.findFirst({ + where: and(eq(mealPlanMembers.mealPlanId, mealPlanId), eq(mealPlanMembers.userId, userId)), + }); + if (!member) return null; + + return { plan, role: member.role }; +} + +export function canWriteMealPlan(role: MealPlanRole): boolean { + return role === "owner" || role === "editor"; +} diff --git a/apps/web/lib/shopping-list-access.ts b/apps/web/lib/shopping-list-access.ts new file mode 100644 index 0000000..9c8ddbd --- /dev/null +++ b/apps/web/lib/shopping-list-access.ts @@ -0,0 +1,29 @@ +import { db, shoppingLists, shoppingListMembers, eq, and } from "@epicure/db"; + +export type ShoppingListRole = "owner" | "editor" | "viewer"; + +export type ShoppingListAccess = { + list: typeof shoppingLists.$inferSelect; + role: ShoppingListRole; +}; + +/** Resolves a user's access to a shopping list — owner, or member with their assigned role. Null if no access. */ +export async function getShoppingListAccess( + listId: string, + userId: string +): Promise { + const list = await db.query.shoppingLists.findFirst({ where: eq(shoppingLists.id, listId) }); + if (!list) return null; + if (list.userId === userId) return { list, role: "owner" }; + + const member = await db.query.shoppingListMembers.findFirst({ + where: and(eq(shoppingListMembers.listId, listId), eq(shoppingListMembers.userId, userId)), + }); + if (!member) return null; + + return { list, role: member.role }; +} + +export function canWriteShoppingList(role: ShoppingListRole): boolean { + return role === "owner" || role === "editor"; +} diff --git a/apps/web/messages/en.json b/apps/web/messages/en.json index 6c91f8c..158bcb1 100644 --- a/apps/web/messages/en.json +++ b/apps/web/messages/en.json @@ -327,7 +327,8 @@ "partialMatches": "Partial matches", "noMatches": "No strong matches found", "missing": "Missing", - "ingredientProgress": "{matched}/{total} ingredients" + "ingredientProgress": "{matched}/{total} ingredients", + "useItUp": "Use it up" }, "mealPlan": { "title": "Meal Plan", @@ -386,6 +387,8 @@ "following": "Following", "trending": "Trending", "trendingEmpty": "No trending recipes this week.", + "forYou": "For You", + "forYouEmpty": "Favorite or rate some recipes to get personalized picks here.", "loading": "Loading…" }, "shoppingLists": { diff --git a/apps/web/messages/fr.json b/apps/web/messages/fr.json index f34855d..6dde79c 100644 --- a/apps/web/messages/fr.json +++ b/apps/web/messages/fr.json @@ -315,7 +315,8 @@ "partialMatches": "Correspondances partielles", "noMatches": "Aucune correspondance trouvée", "missing": "Manquant", - "ingredientProgress": "{matched}/{total} ingrédients" + "ingredientProgress": "{matched}/{total} ingrédients", + "useItUp": "À utiliser vite" }, "mealPlan": { "title": "Planning repas", @@ -374,6 +375,8 @@ "following": "Abonnements", "trending": "Tendances", "trendingEmpty": "Aucune recette tendance cette semaine.", + "forYou": "Pour vous", + "forYouEmpty": "Ajoutez des favoris ou notez des recettes pour des suggestions personnalisées.", "loading": "Chargement…" }, "shoppingLists": { diff --git a/apps/web/public/icon-192.svg b/apps/web/public/icon-192.svg new file mode 100644 index 0000000..12c3627 --- /dev/null +++ b/apps/web/public/icon-192.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/icon-512.svg b/apps/web/public/icon-512.svg new file mode 100644 index 0000000..34d53df --- /dev/null +++ b/apps/web/public/icon-512.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/apps/web/public/manifest.json b/apps/web/public/manifest.json new file mode 100644 index 0000000..2de4478 --- /dev/null +++ b/apps/web/public/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "Epicure", + "short_name": "Epicure", + "description": "Your personal AI-powered recipe book.", + "start_url": "/", + "display": "standalone", + "background_color": "#ffffff", + "theme_color": "#18181b", + "icons": [ + { "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/icon-192.svg", "sizes": "192x192", "type": "image/svg+xml", "purpose": "maskable" }, + { "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "any" }, + { "src": "/icon-512.svg", "sizes": "512x512", "type": "image/svg+xml", "purpose": "maskable" } + ] +} diff --git a/packages/db/src/index.ts b/packages/db/src/index.ts index 54a209b..ed9b1fc 100644 --- a/packages/db/src/index.ts +++ b/packages/db/src/index.ts @@ -2,4 +2,4 @@ export { db } from "./client"; export type { Db } from "./client"; export * from "./schema"; // Re-export query helpers so all callers use the same drizzle-orm instance -export { eq, and, or, desc, asc, gt, lt, gte, lte, ne, inArray, isNull, isNotNull, sql, count, avg, sum, min, max, ilike, like } from "drizzle-orm"; +export { eq, and, or, desc, asc, gt, lt, gte, lte, ne, inArray, notInArray, isNull, isNotNull, sql, count, avg, sum, min, max, ilike, like } from "drizzle-orm"; diff --git a/packages/db/src/migrations/0014_late_marvex.sql b/packages/db/src/migrations/0014_late_marvex.sql new file mode 100644 index 0000000..ba51801 --- /dev/null +++ b/packages/db/src/migrations/0014_late_marvex.sql @@ -0,0 +1,24 @@ +CREATE TABLE "meal_plan_members" ( + "id" text PRIMARY KEY NOT NULL, + "meal_plan_id" text NOT NULL, + "user_id" text NOT NULL, + "role" "collection_member_role" DEFAULT 'viewer' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "shopping_list_members" ( + "id" text PRIMARY KEY NOT NULL, + "list_id" text NOT NULL, + "user_id" text NOT NULL, + "role" "collection_member_role" DEFAULT 'viewer' NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "meal_plan_members" ADD CONSTRAINT "meal_plan_members_meal_plan_id_meal_plans_id_fk" FOREIGN KEY ("meal_plan_id") REFERENCES "public"."meal_plans"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "meal_plan_members" ADD CONSTRAINT "meal_plan_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shopping_list_members" ADD CONSTRAINT "shopping_list_members_list_id_shopping_lists_id_fk" FOREIGN KEY ("list_id") REFERENCES "public"."shopping_lists"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "shopping_list_members" ADD CONSTRAINT "shopping_list_members_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "meal_plan_members_plan_idx" ON "meal_plan_members" USING btree ("meal_plan_id");--> statement-breakpoint +CREATE INDEX "meal_plan_members_user_idx" ON "meal_plan_members" USING btree ("user_id");--> statement-breakpoint +CREATE INDEX "shopping_list_members_list_idx" ON "shopping_list_members" USING btree ("list_id");--> statement-breakpoint +CREATE INDEX "shopping_list_members_user_idx" ON "shopping_list_members" USING btree ("user_id"); \ No newline at end of file diff --git a/packages/db/src/migrations/meta/0014_snapshot.json b/packages/db/src/migrations/meta/0014_snapshot.json new file mode 100644 index 0000000..dfd797a --- /dev/null +++ b/packages/db/src/migrations/meta/0014_snapshot.json @@ -0,0 +1,3512 @@ +{ + "id": "81d04db7-c9ad-4aaf-92bc-eb0c52e139c8", + "prevId": "c21de13e-ba41-4de7-9af9-0995f806c66b", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "accounts_user_id_users_id_fk": { + "name": "accounts_user_id_users_id_fk", + "tableFrom": "accounts", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_keys": { + "name": "api_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "api_keys_user_id_users_id_fk": { + "name": "api_keys_user_id_users_id_fk", + "tableFrom": "api_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_keys_key_hash_unique": { + "name": "api_keys_key_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "key_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.push_subscriptions": { + "name": "push_subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "p256dh": { + "name": "p256dh", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "auth": { + "name": "auth", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "push_subscriptions_user_id_users_id_fk": { + "name": "push_subscriptions_user_id_users_id_fk", + "tableFrom": "push_subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "push_subscriptions_endpoint_unique": { + "name": "push_subscriptions_endpoint_unique", + "nullsNotDistinct": false, + "columns": [ + "endpoint" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_unique": { + "name": "sessions_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_ai_keys": { + "name": "user_ai_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_key": { + "name": "encrypted_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_ai_keys_user_id_users_id_fk": { + "name": "user_ai_keys_user_id_users_id_fk", + "tableFrom": "user_ai_keys", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_follows": { + "name": "user_follows", + "schema": "", + "columns": { + "follower_id": { + "name": "follower_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "following_id": { + "name": "following_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_follows_follower_id_users_id_fk": { + "name": "user_follows_follower_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "follower_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_follows_following_id_users_id_fk": { + "name": "user_follows_following_id_users_id_fk", + "tableFrom": "user_follows", + "tableTo": "users", + "columnsFrom": [ + "following_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_model_prefs": { + "name": "user_model_prefs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "text_provider": { + "name": "text_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "text_model": { + "name": "text_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_provider": { + "name": "vision_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "vision_model": { + "name": "vision_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_provider": { + "name": "meal_plan_provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "meal_plan_model": { + "name": "meal_plan_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_model_prefs_user_id_users_id_fk": { + "name": "user_model_prefs_user_id_users_id_fk", + "tableFrom": "user_model_prefs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_model_prefs_user_id_unique": { + "name": "user_model_prefs_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_nutrition_goals": { + "name": "user_nutrition_goals", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "calories_kcal": { + "name": "calories_kcal", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "protein_g": { + "name": "protein_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "carbs_g": { + "name": "carbs_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "fat_g": { + "name": "fat_g", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "user_nutrition_goals_user_id_users_id_fk": { + "name": "user_nutrition_goals_user_id_users_id_fk", + "tableFrom": "user_nutrition_goals", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_nutrition_goals_user_id_unique": { + "name": "user_nutrition_goals_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "bio": { + "name": "bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "private_bio": { + "name": "private_bio", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "username": { + "name": "username", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "user_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'user'" + }, + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'free'" + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "unit_pref": { + "name": "unit_pref", + "type": "unit_pref", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'metric'" + }, + "locale": { + "name": "locale", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'en'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "users_username_unique": { + "name": "users_username_unique", + "nullsNotDistinct": false, + "columns": [ + "username" + ] + }, + "users_stripe_customer_id_unique": { + "name": "users_stripe_customer_id_unique", + "nullsNotDistinct": false, + "columns": [ + "stripe_customer_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verifications": { + "name": "verifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ingredients": { + "name": "ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "aliases": { + "name": "aliases", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "category": { + "name": "category", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "known_allergens": { + "name": "known_allergens", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "ingredients_name_unique": { + "name": "ingredients_name_unique", + "nullsNotDistinct": false, + "columns": [ + "name" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_ingredients": { + "name": "recipe_ingredients", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_ingredients_recipe_id_recipes_id_fk": { + "name": "recipe_ingredients_recipe_id_recipes_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_ingredients_ingredient_id_ingredients_id_fk": { + "name": "recipe_ingredients_ingredient_id_ingredients_id_fk", + "tableFrom": "recipe_ingredients", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_notes": { + "name": "recipe_notes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_notes_recipe_id_recipes_id_fk": { + "name": "recipe_notes_recipe_id_recipes_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_notes_user_id_users_id_fk": { + "name": "recipe_notes_user_id_users_id_fk", + "tableFrom": "recipe_notes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_photos": { + "name": "recipe_photos", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_cover": { + "name": "is_cover", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_photos_recipe_id_recipes_id_fk": { + "name": "recipe_photos_recipe_id_recipes_id_fk", + "tableFrom": "recipe_photos", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_snapshots": { + "name": "recipe_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_data": { + "name": "snapshot_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipe_snapshots_recipe_idx": { + "name": "recipe_snapshots_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "recipe_snapshots_recipe_id_recipes_id_fk": { + "name": "recipe_snapshots_recipe_id_recipes_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_snapshots_author_id_users_id_fk": { + "name": "recipe_snapshots_author_id_users_id_fk", + "tableFrom": "recipe_snapshots", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_steps": { + "name": "recipe_steps", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "order": { + "name": "order", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timer_seconds": { + "name": "timer_seconds", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "photo_url": { + "name": "photo_url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_steps_recipe_id_recipes_id_fk": { + "name": "recipe_steps_recipe_id_recipes_id_fk", + "tableFrom": "recipe_steps", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipe_variations": { + "name": "recipe_variations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_recipe_id": { + "name": "parent_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_recipe_id": { + "name": "child_recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "recipe_variations_parent_recipe_id_recipes_id_fk": { + "name": "recipe_variations_parent_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "parent_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "recipe_variations_child_recipe_id_recipes_id_fk": { + "name": "recipe_variations_child_recipe_id_recipes_id_fk", + "tableFrom": "recipe_variations", + "tableTo": "recipes", + "columnsFrom": [ + "child_recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.recipes": { + "name": "recipes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "author_id": { + "name": "author_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_servings": { + "name": "base_servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 4 + }, + "visibility": { + "name": "visibility", + "type": "visibility", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'private'" + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_generated": { + "name": "ai_generated", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "ai_model": { + "name": "ai_model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ai_prompt": { + "name": "ai_prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "language": { + "name": "language", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "dietary_tags": { + "name": "dietary_tags", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'::jsonb" + }, + "dietary_verified": { + "name": "dietary_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "nutrition_data": { + "name": "nutrition_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "difficulty": { + "name": "difficulty", + "type": "difficulty", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "prep_mins": { + "name": "prep_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "cook_mins": { + "name": "cook_mins", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tags": { + "name": "tags", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "recipes_author_idx": { + "name": "recipes_author_idx", + "columns": [ + { + "expression": "author_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_visibility_idx": { + "name": "recipes_visibility_idx", + "columns": [ + { + "expression": "visibility", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "recipes_dietary_tags_gin": { + "name": "recipes_dietary_tags_gin", + "columns": [ + { + "expression": "dietary_tags", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "recipes_author_id_users_id_fk": { + "name": "recipes_author_id_users_id_fk", + "tableFrom": "recipes", + "tableTo": "users", + "columnsFrom": [ + "author_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_allergens": { + "name": "user_allergens", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "allergen_tag": { + "name": "allergen_tag", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "user_allergens_user_id_users_id_fk": { + "name": "user_allergens_user_id_users_id_fk", + "tableFrom": "user_allergens", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_members": { + "name": "collection_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collection_members_user_idx": { + "name": "collection_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collection_members_collection_id_collections_id_fk": { + "name": "collection_members_collection_id_collections_id_fk", + "tableFrom": "collection_members", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_members_user_id_users_id_fk": { + "name": "collection_members_user_id_users_id_fk", + "tableFrom": "collection_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collection_recipes": { + "name": "collection_recipes", + "schema": "", + "columns": { + "collection_id": { + "name": "collection_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "added_at": { + "name": "added_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "collection_recipes_collection_id_collections_id_fk": { + "name": "collection_recipes_collection_id_collections_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "collections", + "columnsFrom": [ + "collection_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "collection_recipes_recipe_id_recipes_id_fk": { + "name": "collection_recipes_recipe_id_recipes_id_fk", + "tableFrom": "collection_recipes", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.collections": { + "name": "collections", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "collections_user_idx": { + "name": "collections_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "collections_user_id_users_id_fk": { + "name": "collections_user_id_users_id_fk", + "tableFrom": "collections", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comment_reactions": { + "name": "comment_reactions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "comment_id": { + "name": "comment_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "comment_reaction_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comment_reactions_comment_idx": { + "name": "comment_reactions_comment_idx", + "columns": [ + { + "expression": "comment_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comment_reactions_comment_id_comments_id_fk": { + "name": "comment_reactions_comment_id_comments_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "comments", + "columnsFrom": [ + "comment_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comment_reactions_user_id_users_id_fk": { + "name": "comment_reactions_user_id_users_id_fk", + "tableFrom": "comment_reactions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.comments": { + "name": "comments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "comments_recipe_idx": { + "name": "comments_recipe_idx", + "columns": [ + { + "expression": "recipe_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "comments_recipe_id_recipes_id_fk": { + "name": "comments_recipe_id_recipes_id_fk", + "tableFrom": "comments", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "comments_user_id_users_id_fk": { + "name": "comments_user_id_users_id_fk", + "tableFrom": "comments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.cooking_history": { + "name": "cooking_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cooked_at": { + "name": "cooked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "cooking_history_user_idx": { + "name": "cooking_history_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "cooking_history_user_id_users_id_fk": { + "name": "cooking_history_user_id_users_id_fk", + "tableFrom": "cooking_history", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "cooking_history_recipe_id_recipes_id_fk": { + "name": "cooking_history_recipe_id_recipes_id_fk", + "tableFrom": "cooking_history", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.favorites": { + "name": "favorites", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "favorites_user_idx": { + "name": "favorites_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "favorites_user_id_users_id_fk": { + "name": "favorites_user_id_users_id_fk", + "tableFrom": "favorites", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "favorites_recipe_id_recipes_id_fk": { + "name": "favorites_recipe_id_recipes_id_fk", + "tableFrom": "favorites", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.feed_items": { + "name": "feed_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "feed_item_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "subject_id": { + "name": "subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "feed_items_user_idx": { + "name": "feed_items_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "feed_items_user_id_users_id_fk": { + "name": "feed_items_user_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "feed_items_actor_id_users_id_fk": { + "name": "feed_items_actor_id_users_id_fk", + "tableFrom": "feed_items", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.ratings": { + "name": "ratings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "score": { + "name": "score", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "review_text": { + "name": "review_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "ratings_user_idx": { + "name": "ratings_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "ratings_recipe_id_recipes_id_fk": { + "name": "ratings_recipe_id_recipes_id_fk", + "tableFrom": "ratings", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "ratings_user_id_users_id_fk": { + "name": "ratings_user_id_users_id_fk", + "tableFrom": "ratings", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_entries": { + "name": "meal_plan_entries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "day": { + "name": "day", + "type": "weekday", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "meal_type": { + "name": "meal_type", + "type": "meal_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "recipe_id": { + "name": "recipe_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "servings": { + "name": "servings", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 2 + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plan_entries_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_entries_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_entries_recipe_id_recipes_id_fk": { + "name": "meal_plan_entries_recipe_id_recipes_id_fk", + "tableFrom": "meal_plan_entries", + "tableTo": "recipes", + "columnsFrom": [ + "recipe_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plan_members": { + "name": "meal_plan_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "meal_plan_id": { + "name": "meal_plan_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "meal_plan_members_plan_idx": { + "name": "meal_plan_members_plan_idx", + "columns": [ + { + "expression": "meal_plan_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "meal_plan_members_user_idx": { + "name": "meal_plan_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "meal_plan_members_meal_plan_id_meal_plans_id_fk": { + "name": "meal_plan_members_meal_plan_id_meal_plans_id_fk", + "tableFrom": "meal_plan_members", + "tableTo": "meal_plans", + "columnsFrom": [ + "meal_plan_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "meal_plan_members_user_id_users_id_fk": { + "name": "meal_plan_members_user_id_users_id_fk", + "tableFrom": "meal_plan_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.meal_plans": { + "name": "meal_plans", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "week_start": { + "name": "week_start", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "meal_plans_user_id_users_id_fk": { + "name": "meal_plans_user_id_users_id_fk", + "tableFrom": "meal_plans", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pantry_items": { + "name": "pantry_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "pantry_items_user_id_users_id_fk": { + "name": "pantry_items_user_id_users_id_fk", + "tableFrom": "pantry_items", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pantry_items_ingredient_id_ingredients_id_fk": { + "name": "pantry_items_ingredient_id_ingredients_id_fk", + "tableFrom": "pantry_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_items": { + "name": "shopping_list_items", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ingredient_id": { + "name": "ingredient_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "raw_name": { + "name": "raw_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "quantity": { + "name": "quantity", + "type": "numeric(10, 4)", + "primaryKey": false, + "notNull": false + }, + "unit": { + "name": "unit", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "aisle": { + "name": "aisle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "checked": { + "name": "checked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_list_items_list_id_shopping_lists_id_fk": { + "name": "shopping_list_items_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_items_ingredient_id_ingredients_id_fk": { + "name": "shopping_list_items_ingredient_id_ingredients_id_fk", + "tableFrom": "shopping_list_items", + "tableTo": "ingredients", + "columnsFrom": [ + "ingredient_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_list_members": { + "name": "shopping_list_members", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "list_id": { + "name": "list_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "collection_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'viewer'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "shopping_list_members_list_idx": { + "name": "shopping_list_members_list_idx", + "columns": [ + { + "expression": "list_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "shopping_list_members_user_idx": { + "name": "shopping_list_members_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "shopping_list_members_list_id_shopping_lists_id_fk": { + "name": "shopping_list_members_list_id_shopping_lists_id_fk", + "tableFrom": "shopping_list_members", + "tableTo": "shopping_lists", + "columnsFrom": [ + "list_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "shopping_list_members_user_id_users_id_fk": { + "name": "shopping_list_members_user_id_users_id_fk", + "tableFrom": "shopping_list_members", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.shopping_lists": { + "name": "shopping_lists", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "shopping_lists_user_id_users_id_fk": { + "name": "shopping_lists_user_id_users_id_fk", + "tableFrom": "shopping_lists", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_logs": { + "name": "audit_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_type": { + "name": "target_type", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_logs_created_idx": { + "name": "audit_logs_created_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_logs_user_id_users_id_fk": { + "name": "audit_logs_user_id_users_id_fk", + "tableFrom": "audit_logs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.site_settings": { + "name": "site_settings", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_secret": { + "name": "is_secret", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_by_id": { + "name": "updated_by_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "site_settings_updated_by_id_users_id_fk": { + "name": "site_settings_updated_by_id_users_id_fk", + "tableFrom": "site_settings", + "tableTo": "users", + "columnsFrom": [ + "updated_by_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tier_definitions": { + "name": "tier_definitions", + "schema": "", + "columns": { + "tier": { + "name": "tier", + "type": "tier", + "typeSchema": "public", + "primaryKey": true, + "notNull": true + }, + "max_recipes": { + "name": "max_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "ai_calls_per_month": { + "name": "ai_calls_per_month", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "storage_mb": { + "name": "storage_mb", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "max_public_recipes": { + "name": "max_public_recipes", + "type": "integer", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_usage": { + "name": "user_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "month": { + "name": "month", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ai_calls_used": { + "name": "ai_calls_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "recipe_count": { + "name": "recipe_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "storage_used_mb": { + "name": "storage_used_mb", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": { + "user_usage_user_month_idx": { + "name": "user_usage_user_month_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "month", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_usage_user_id_users_id_fk": { + "name": "user_usage_user_id_users_id_fk", + "tableFrom": "user_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_usage_user_month_uniq": { + "name": "user_usage_user_month_uniq", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "month" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook_deliveries": { + "name": "webhook_deliveries", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event": { + "name": "event", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status_code": { + "name": "status_code", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "success": { + "name": "success", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhook_deliveries_webhook_id_webhooks_id_fk": { + "name": "webhook_deliveries_webhook_id_webhooks_id_fk", + "tableFrom": "webhook_deliveries", + "tableTo": "webhooks", + "columnsFrom": [ + "webhook_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhooks": { + "name": "webhooks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "events": { + "name": "events", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active": { + "name": "active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "webhooks_user_id_users_id_fk": { + "name": "webhooks_user_id_users_id_fk", + "tableFrom": "webhooks", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.tier": { + "name": "tier", + "schema": "public", + "values": [ + "free", + "pro" + ] + }, + "public.unit_pref": { + "name": "unit_pref", + "schema": "public", + "values": [ + "metric", + "imperial" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "user", + "moderator", + "admin" + ] + }, + "public.difficulty": { + "name": "difficulty", + "schema": "public", + "values": [ + "easy", + "medium", + "hard" + ] + }, + "public.visibility": { + "name": "visibility", + "schema": "public", + "values": [ + "private", + "unlisted", + "public" + ] + }, + "public.collection_member_role": { + "name": "collection_member_role", + "schema": "public", + "values": [ + "viewer", + "editor" + ] + }, + "public.comment_reaction_type": { + "name": "comment_reaction_type", + "schema": "public", + "values": [ + "like", + "love", + "laugh", + "wow", + "sad", + "fire" + ] + }, + "public.feed_item_type": { + "name": "feed_item_type", + "schema": "public", + "values": [ + "new_recipe", + "new_follow", + "recipe_rated" + ] + }, + "public.meal_type": { + "name": "meal_type", + "schema": "public", + "values": [ + "breakfast", + "lunch", + "dinner", + "snack" + ] + }, + "public.weekday": { + "name": "weekday", + "schema": "public", + "values": [ + "mon", + "tue", + "wed", + "thu", + "fri", + "sat", + "sun" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 608a6c6..9c9872b 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -85,6 +85,27 @@ "when": 1782896400709, "tag": "0011_premium_agent_zero", "breakpoints": true + }, + { + "idx": 12, + "version": "7", + "when": 1782977005184, + "tag": "0012_sloppy_tigra", + "breakpoints": true + }, + { + "idx": 13, + "version": "7", + "when": 1782977520719, + "tag": "0013_damp_richard_fisk", + "breakpoints": true + }, + { + "idx": 14, + "version": "7", + "when": 1782984288632, + "tag": "0014_late_marvex", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/meal-planning.ts b/packages/db/src/schema/meal-planning.ts index b9c20e7..b7871be 100644 --- a/packages/db/src/schema/meal-planning.ts +++ b/packages/db/src/schema/meal-planning.ts @@ -7,11 +7,13 @@ import { date, decimal, pgEnum, + index, } from "drizzle-orm/pg-core"; import { relations } from "drizzle-orm"; import { users } from "./users"; import { recipes } from "./recipes"; import { ingredients } from "./recipes"; +import { collectionMemberRoleEnum } from "./social"; export const mealTypeEnum = pgEnum("meal_type", ["breakfast", "lunch", "dinner", "snack"]); export const weekdayEnum = pgEnum("weekday", ["mon", "tue", "wed", "thu", "fri", "sat", "sun"]); @@ -63,9 +65,32 @@ export const shoppingListItems = pgTable("shopping_list_items", { checked: boolean("checked").notNull().default(false), }); +export const shoppingListMembers = pgTable("shopping_list_members", { + id: text("id").primaryKey(), + listId: text("list_id").notNull().references(() => shoppingLists.id, { onDelete: "cascade" }), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + role: collectionMemberRoleEnum("role").notNull().default("viewer"), + createdAt: timestamp("created_at").notNull().defaultNow(), +}, (t) => [ + index("shopping_list_members_list_idx").on(t.listId), + index("shopping_list_members_user_idx").on(t.userId), +]); + +export const mealPlanMembers = pgTable("meal_plan_members", { + id: text("id").primaryKey(), + mealPlanId: text("meal_plan_id").notNull().references(() => mealPlans.id, { onDelete: "cascade" }), + userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), + role: collectionMemberRoleEnum("role").notNull().default("viewer"), + createdAt: timestamp("created_at").notNull().defaultNow(), +}, (t) => [ + index("meal_plan_members_plan_idx").on(t.mealPlanId), + index("meal_plan_members_user_idx").on(t.userId), +]); + export const mealPlansRelations = relations(mealPlans, ({ one, many }) => ({ user: one(users, { fields: [mealPlans.userId], references: [users.id] }), entries: many(mealPlanEntries), + members: many(mealPlanMembers), })); export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) => ({ @@ -73,11 +98,22 @@ export const mealPlanEntriesRelations = relations(mealPlanEntries, ({ one }) => recipe: one(recipes, { fields: [mealPlanEntries.recipeId], references: [recipes.id] }), })); +export const mealPlanMembersRelations = relations(mealPlanMembers, ({ one }) => ({ + mealPlan: one(mealPlans, { fields: [mealPlanMembers.mealPlanId], references: [mealPlans.id] }), + user: one(users, { fields: [mealPlanMembers.userId], references: [users.id] }), +})); + export const shoppingListsRelations = relations(shoppingLists, ({ one, many }) => ({ user: one(users, { fields: [shoppingLists.userId], references: [users.id] }), items: many(shoppingListItems), + members: many(shoppingListMembers), })); export const shoppingListItemsRelations = relations(shoppingListItems, ({ one }) => ({ list: one(shoppingLists, { fields: [shoppingListItems.listId], references: [shoppingLists.id] }), })); + +export const shoppingListMembersRelations = relations(shoppingListMembers, ({ one }) => ({ + list: one(shoppingLists, { fields: [shoppingListMembers.listId], references: [shoppingLists.id] }), + user: one(users, { fields: [shoppingListMembers.userId], references: [users.id] }), +})); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1bd202e..f8633ff 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -56,6 +56,9 @@ importers: cmdk: specifier: ^1.1.1 version: 1.1.1(@types/react-dom@19.2.3(@types/react@19.2.17))(@types/react@19.2.17)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + diff: + specifier: ^9.0.0 + version: 9.0.0 drizzle-orm: specifier: ^0.44.7 version: 0.44.7(@opentelemetry/api@1.9.1)(kysely@0.29.2)(postgres@3.4.9) @@ -176,6 +179,9 @@ importers: specifier: ^3.4.7 version: 3.4.9 devDependencies: + '@types/node': + specifier: ^20.19.43 + version: 20.19.43 drizzle-kit: specifier: ^0.31.1 version: 0.31.10 @@ -2982,6 +2988,10 @@ packages: resolution: {integrity: sha512-DPi0FmjiSU5EvQV0++GFDOJ9ASQUVFh5kD+OzOnYdi7n3Wpm9hWWGfB/O2blfHcMVTL5WkQXSnRiK9makhrcnw==} engines: {node: '>=0.3.1'} + diff@9.0.0: + resolution: {integrity: sha512-svtcdpS8CgJyqAjEQIXdb3OjhFVVYjzGAPO8WGCmRbrml64SPw/jJD4GoE98aR7r25A0XcgrK3F02yw9R/vhQw==} + engines: {node: '>=0.3.1'} + doctrine@2.1.0: resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} engines: {node: '>=0.10.0'} @@ -7865,6 +7875,8 @@ snapshots: diff@8.0.4: {} + diff@9.0.0: {} + doctrine@2.1.0: dependencies: esutils: 2.0.3