Compare commits

..

2 Commits

Author SHA1 Message Date
Arnaud b4b964aafb feat: add trending/leaderboard for collections
New collection_favorites table lets users star public collections.
/collections/explore mirrors the recipe explore page: trending (star
count in last 7 days) and recently-added public collections. Linked
from the "Discover" button on the collections page.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:33:40 +02:00
Arnaud cd444d4d23 feat: surface pantry-expiry recipe suggestions
Pantry page now shows a "Use it up soon" widget with recipes that use
soon-to-expire pantry items, across the user's own recipes plus public/
unlisted ones (the existing /recipes/can-cook page only looked at the
user's own). Extracted the matching/scoring logic shared by both into
lib/pantry-match.ts.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-09 15:26:44 +02:00
15 changed files with 4759 additions and 58 deletions
@@ -0,0 +1,113 @@
import type { Metadata } from "next";
import { headers } from "next/headers";
import { auth } from "@/lib/auth/server";
import {
db,
collections,
users,
collectionFavorites,
collectionRecipes,
eq,
desc,
sql,
and,
gte,
count,
inArray,
} from "@epicure/db";
import { ExploreCollectionsContent } from "@/components/collections/explore-collections-content";
export const metadata: Metadata = {};
export type CollectionResult = {
id: string;
name: string;
description: string | null;
authorName: string | null;
recipeCount: number;
starCount: number;
starred: boolean;
};
export default async function ExploreCollectionsPage() {
const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null;
const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000);
const trendingRows = await db
.select({
id: collections.id,
name: collections.name,
description: collections.description,
authorName: users.name,
recentStars: count(collectionFavorites.collectionId),
})
.from(collections)
.innerJoin(users, eq(collections.userId, users.id))
.leftJoin(
collectionFavorites,
and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo))
)
.where(eq(collections.isPublic, true))
.groupBy(collections.id, users.id)
.orderBy(desc(sql`count(${collectionFavorites.collectionId})`))
.limit(12);
const recentRows = await db
.select({
id: collections.id,
name: collections.name,
description: collections.description,
authorName: users.name,
})
.from(collections)
.innerJoin(users, eq(collections.userId, users.id))
.where(eq(collections.isPublic, true))
.orderBy(desc(collections.createdAt))
.limit(12);
const allIds = [...new Set([...trendingRows.map((r) => r.id), ...recentRows.map((r) => r.id)])];
const [recipeCounts, totalStars, myStars] = await Promise.all([
allIds.length > 0
? db.select({ collectionId: collectionRecipes.collectionId, n: count() })
.from(collectionRecipes)
.where(inArray(collectionRecipes.collectionId, allIds))
.groupBy(collectionRecipes.collectionId)
: [],
allIds.length > 0
? db.select({ collectionId: collectionFavorites.collectionId, n: count() })
.from(collectionFavorites)
.where(inArray(collectionFavorites.collectionId, allIds))
.groupBy(collectionFavorites.collectionId)
: [],
allIds.length > 0
? db.query.collectionFavorites.findMany({
where: and(eq(collectionFavorites.userId, session.user.id), inArray(collectionFavorites.collectionId, allIds)),
columns: { collectionId: true },
})
: [],
]);
const recipeCountMap = new Map(recipeCounts.map((r) => [r.collectionId, r.n]));
const starCountMap = new Map(totalStars.map((r) => [r.collectionId, r.n]));
const starredSet = new Set(myStars.map((s) => s.collectionId));
function toResult(row: { id: string; name: string; description: string | null; authorName: string | null }): CollectionResult {
return {
id: row.id,
name: row.name,
description: row.description,
authorName: row.authorName,
recipeCount: recipeCountMap.get(row.id) ?? 0,
starCount: starCountMap.get(row.id) ?? 0,
starred: starredSet.has(row.id),
};
}
const trending = trendingRows.map(toResult);
const recent = recentRows.map(toResult);
return <ExploreCollectionsContent trending={trending} recent={recent} />;
}
+27 -3
View File
@@ -1,9 +1,13 @@
import type { Metadata } from "next"; import type { Metadata } from "next";
import { headers } from "next/headers"; import { headers } from "next/headers";
import { auth } from "@/lib/auth/server"; import { auth } from "@/lib/auth/server";
import { db, pantryItems, eq, asc } from "@epicure/db"; import { db, pantryItems, recipes, eq, or, inArray } from "@epicure/db";
import { asc } from "@epicure/db";
import { PantryManager } from "@/components/meal-plan/pantry-manager"; import { PantryManager } from "@/components/meal-plan/pantry-manager";
import { PantryPageHeader } from "@/components/pantry/pantry-page-header"; import { PantryPageHeader } from "@/components/pantry/pantry-page-header";
import { ExpiringSoonSuggestions } from "@/components/pantry/expiring-soon-suggestions";
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
import { getPublicUrl } from "@/lib/storage";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
@@ -11,10 +15,16 @@ export default async function PantryPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; if (!session) return null;
const items = await db.query.pantryItems.findMany({ const [items, candidateRecipes] = await Promise.all([
db.query.pantryItems.findMany({
where: eq(pantryItems.userId, session.user.id), where: eq(pantryItems.userId, session.user.id),
orderBy: asc(pantryItems.rawName), orderBy: asc(pantryItems.rawName),
}); }),
db.query.recipes.findMany({
where: or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"])),
with: { ingredients: true, photos: true },
}),
]);
const mappedItems = items.map((i) => ({ const mappedItems = items.map((i) => ({
id: i.id, id: i.id,
@@ -24,9 +34,23 @@ export default async function PantryPage() {
expiresAt: i.expiresAt?.toISOString() ?? null, expiresAt: i.expiresAt?.toISOString() ?? null,
})); }));
const suggestions = scoreRecipesAgainstPantry(candidateRecipes, items)
.filter((s) => s.usesExpiring.length > 0)
.slice(0, 3)
.map((s) => {
const cover = s.recipe.photos?.find((p) => p.isCover) ?? s.recipe.photos?.[0];
return {
id: s.recipe.id,
title: s.recipe.title,
photoUrl: cover ? getPublicUrl(cover.storageKey) : null,
usesExpiring: s.usesExpiring,
};
});
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<PantryPageHeader items={mappedItems} /> <PantryPageHeader items={mappedItems} />
<ExpiringSoonSuggestions suggestions={suggestions} />
<PantryManager initialItems={mappedItems} /> <PantryManager initialItems={mappedItems} />
</div> </div>
); );
+7 -45
View File
@@ -5,17 +5,10 @@ import { db, recipes, pantryItems } from "@epicure/db";
import { eq } from "@epicure/db"; import { eq } from "@epicure/db";
import { getPublicUrl } from "@/lib/storage"; import { getPublicUrl } from "@/lib/storage";
import { CanCookContent } from "@/components/recipe/can-cook-content"; import { CanCookContent } from "@/components/recipe/can-cook-content";
import { scoreRecipesAgainstPantry } from "@/lib/pantry-match";
export const metadata: Metadata = {}; export const metadata: Metadata = {};
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() { export default async function CanCookPage() {
const session = await auth.api.getSession({ headers: await headers() }); const session = await auth.api.getSession({ headers: await headers() });
if (!session) return null; if (!session) return null;
@@ -33,48 +26,17 @@ export default async function CanCookPage() {
}), }),
]); ]);
const pantryKeys = new Set(pantry.map((p) => p.rawName.toLowerCase())); const scored = scoreRecipesAgainstPantry(userRecipes, pantry).map((s) => {
const cover = s.recipe.photos?.find((p) => p.isCover) ?? s.recipe.photos?.[0];
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) => {
const matched = recipe.ingredients.filter((ing) =>
pantryKeys.has(ing.rawName.toLowerCase())
).length;
const missing = recipe.ingredients
.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 { return {
...s,
recipe: { recipe: {
id: recipe.id, id: s.recipe.id,
title: recipe.title, title: s.recipe.title,
description: recipe.description, description: s.recipe.description,
photos: cover ? [{ url: getPublicUrl(cover.storageKey) }] : [], photos: cover ? [{ url: getPublicUrl(cover.storageKey) }] : [],
}, },
matched,
total,
pct: Math.round((matched / total) * 100),
missing,
usesExpiring,
}; };
})
.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 <CanCookContent pantryCount={pantry.length} scored={scored} />; return <CanCookContent pantryCount={pantry.length} scored={scored} />;
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from "next/server";
import { db, collections, collectionFavorites, eq, and, or } from "@epicure/db";
import { requireSession } from "@/lib/api-auth";
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 collection = await db.query.collections.findFirst({
where: and(eq(collections.id, id), or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id))),
columns: { id: true },
});
if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 });
await db.insert(collectionFavorites)
.values({ userId: session!.user.id, collectionId: id })
.onConflictDoNothing();
return NextResponse.json({ favorited: true });
}
export async function DELETE(_req: NextRequest, { params }: Params) {
const { session, response } = await requireSession();
if (response) return response;
const { id } = await params;
await db.delete(collectionFavorites)
.where(and(eq(collectionFavorites.userId, session!.user.id), eq(collectionFavorites.collectionId, id)));
return NextResponse.json({ favorited: false });
}
@@ -0,0 +1,43 @@
"use client";
import { useState } from "react";
import { Star } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
export function CollectionStarButton({
collectionId,
initialStarred = false,
starCount,
}: {
collectionId: string;
initialStarred?: boolean;
starCount: number;
}) {
const [starred, setStarred] = useState(initialStarred);
const [count, setCount] = useState(starCount);
const [loading, setLoading] = useState(false);
async function toggle(e: React.MouseEvent) {
e.preventDefault();
e.stopPropagation();
setLoading(true);
try {
const res = await fetch(`/api/v1/collections/${collectionId}/favorite`, {
method: starred ? "DELETE" : "POST",
});
if (!res.ok) return;
setStarred(!starred);
setCount((c) => (starred ? c - 1 : c + 1));
} finally {
setLoading(false);
}
}
return (
<Button variant="ghost" size="sm" onClick={toggle} disabled={loading} className="gap-1.5 px-2">
<Star className={cn("h-4 w-4", starred && "fill-yellow-400 text-yellow-400")} />
<span className="text-xs tabular-nums">{count}</span>
</Button>
);
}
@@ -1,9 +1,11 @@
"use client"; "use client";
import { useTranslations } from "next-intl"; import { useTranslations } from "next-intl";
import Link from "next/link"; import Link from "next/link";
import { FolderOpen } from "lucide-react"; import { FolderOpen, Flame } from "lucide-react";
import { Badge } from "@/components/ui/badge"; import { Badge } from "@/components/ui/badge";
import { buttonVariants } from "@/components/ui/button";
import { NewCollectionButton } from "@/components/social/new-collection-button"; import { NewCollectionButton } from "@/components/social/new-collection-button";
import { cn } from "@/lib/utils";
type Collection = { type Collection = {
id: string; id: string;
@@ -27,8 +29,14 @@ export function CollectionsPageContent({ collections }: Props) {
<h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1> <h1 className="text-3xl font-bold tracking-tight">{t("title")}</h1>
<p className="text-muted-foreground mt-1">{t("subtitle")}</p> <p className="text-muted-foreground mt-1">{t("subtitle")}</p>
</div> </div>
<div className="flex items-center gap-2">
<Link href="/collections/explore" className={cn(buttonVariants({ variant: "outline", size: "sm" }), "gap-1.5")}>
<Flame className="h-4 w-4 text-orange-500" />
{t("discoverTitle")}
</Link>
<NewCollectionButton /> <NewCollectionButton />
</div> </div>
</div>
{collections.length === 0 ? ( {collections.length === 0 ? (
<div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4"> <div className="flex flex-col items-center justify-center h-64 border-2 border-dashed rounded-xl gap-4">
@@ -0,0 +1,99 @@
"use client";
import Link from "next/link";
import { Flame, Clock, FolderOpen, ChefHat } from "lucide-react";
import { useTranslations } from "next-intl";
import { CollectionStarButton } from "@/components/collections/collection-star-button";
import type { CollectionResult } from "@/app/(app)/collections/explore/page";
function CollectionCard({ collection }: { collection: CollectionResult }) {
const t = useTranslations("collections");
return (
<div className="group relative rounded-lg border bg-card p-4 shadow-sm transition-shadow hover:shadow-md">
<Link href={`/collections/${collection.id}`} className="block">
<h3 className="font-semibold text-card-foreground group-hover:text-primary line-clamp-1 pr-10">
{collection.name}
</h3>
{collection.description && (
<p className="mt-1 text-sm text-muted-foreground line-clamp-2">{collection.description}</p>
)}
<div className="mt-3 flex items-center gap-3 text-xs text-muted-foreground">
<span className="flex items-center gap-1">
<FolderOpen className="h-3.5 w-3.5" />
{collection.recipeCount !== 1
? t("recipeCountPlural", { count: collection.recipeCount })
: t("recipeCount", { count: collection.recipeCount })}
</span>
{collection.authorName && (
<span className="flex items-center gap-1">
<ChefHat className="h-3.5 w-3.5" />
{collection.authorName}
</span>
)}
</div>
</Link>
<div className="absolute top-3 right-3">
<CollectionStarButton collectionId={collection.id} initialStarred={collection.starred} starCount={collection.starCount} />
</div>
</div>
);
}
function HorizontalScroll({ children }: { children: React.ReactNode }) {
return <div className="flex gap-4 overflow-x-auto pb-2 -mx-4 px-4 snap-x snap-mandatory">{children}</div>;
}
type Props = {
trending: CollectionResult[];
recent: CollectionResult[];
};
export function ExploreCollectionsContent({ trending, recent }: Props) {
const t = useTranslations("collections");
return (
<div className="max-w-5xl mx-auto space-y-10">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">{t("discoverTitle")}</h1>
<p className="text-muted-foreground mt-1">{t("discoverSubtitle")}</p>
</div>
<Link href="/collections" className="text-sm text-muted-foreground hover:text-foreground underline underline-offset-4">
{t("myCollectionsLink")}
</Link>
</div>
<section>
<div className="flex items-center gap-2 mb-4">
<Flame className="h-5 w-5 text-orange-500" />
<h2 className="text-xl font-semibold">{t("trendingThisWeek")}</h2>
</div>
{trending.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">{t("noTrending")}</p>
) : (
<HorizontalScroll>
{trending.map((c) => (
<div key={c.id} className="snap-start shrink-0 w-64">
<CollectionCard collection={c} />
</div>
))}
</HorizontalScroll>
)}
</section>
<section>
<div className="flex items-center gap-2 mb-4">
<Clock className="h-5 w-5 text-blue-500" />
<h2 className="text-xl font-semibold">{t("recentlyAdded")}</h2>
</div>
{recent.length === 0 ? (
<p className="text-muted-foreground text-sm py-8 text-center">{t("noRecent")}</p>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{recent.map((c) => <CollectionCard key={c.id} collection={c} />)}
</div>
)}
</section>
</div>
);
}
@@ -0,0 +1,55 @@
"use client";
import Link from "next/link";
import { useTranslations } from "next-intl";
import { Clock } from "lucide-react";
import { Badge } from "@/components/ui/badge";
type Suggestion = {
id: string;
title: string;
photoUrl: string | null;
usesExpiring: string[];
};
export function ExpiringSoonSuggestions({ suggestions }: { suggestions: Suggestion[] }) {
const t = useTranslations("pantry");
if (suggestions.length === 0) return null;
return (
<div className="rounded-xl border p-4 space-y-3">
<div className="flex items-center justify-between">
<h2 className="font-semibold flex items-center gap-2">
<Clock className="h-4 w-4 text-orange-500" />
{t("expiringSoonSuggestionsTitle")}
</h2>
<Link href="/recipes/can-cook" className="text-sm text-primary hover:underline">
{t("seeAll")}
</Link>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3">
{suggestions.map((s) => (
<Link
key={s.id}
href={`/recipes/${s.id}`}
className="rounded-lg border overflow-hidden hover:bg-accent transition-colors"
>
{s.photoUrl ? (
<img src={s.photoUrl} alt="" className="h-24 w-full object-cover" />
) : (
<div className="h-24 w-full bg-muted" />
)}
<div className="p-2 space-y-1">
<p className="text-sm font-medium truncate">{s.title}</p>
<Badge variant="outline" className="text-orange-500 border-orange-500 gap-1 text-[11px]">
<Clock className="h-3 w-3" />
{s.usesExpiring.slice(0, 2).join(", ")}
</Badge>
</div>
</Link>
))}
</div>
</div>
);
}
+40
View File
@@ -0,0 +1,40 @@
export const EXPIRING_WITHIN_DAYS = 3;
export 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;
}
type ScorableRecipe<T> = T & { ingredients: { rawName: string }[] };
export function scoreRecipesAgainstPantry<T>(
recipesList: ScorableRecipe<T>[],
pantry: { rawName: string; expiresAt: Date | null }[]
) {
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())
);
return recipesList
.filter((r) => r.ingredients.length > 0)
.map((recipe) => {
const matched = recipe.ingredients.filter((ing) => pantryKeys.has(ing.rawName.toLowerCase())).length;
const missing = recipe.ingredients
.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;
return { recipe, matched, total, pct: Math.round((matched / total) * 100), missing, usesExpiring };
})
.sort((a, b) => {
if (a.usesExpiring.length > 0 !== b.usesExpiring.length > 0) {
return a.usesExpiring.length > 0 ? -1 : 1;
}
return b.pct - a.pct;
});
}
+9
View File
@@ -622,6 +622,8 @@
"title": "Pantry", "title": "Pantry",
"subtitle": "Track what you have on hand", "subtitle": "Track what you have on hand",
"canCook": "What can I cook?", "canCook": "What can I cook?",
"expiringSoonSuggestionsTitle": "Use it up soon",
"seeAll": "See all",
"itemNamePlaceholder": "Item name", "itemNamePlaceholder": "Item name",
"qtyPlaceholder": "Qty", "qtyPlaceholder": "Qty",
"unitPlaceholder": "Unit", "unitPlaceholder": "Unit",
@@ -691,6 +693,13 @@
"public": "Public", "public": "Public",
"recipeCount": "{count} recipe", "recipeCount": "{count} recipe",
"recipeCountPlural": "{count} recipes", "recipeCountPlural": "{count} recipes",
"discoverTitle": "Discover",
"discoverSubtitle": "Trending and recently shared public collections",
"myCollectionsLink": "My collections",
"trendingThisWeek": "Trending this week",
"recentlyAdded": "Recently added",
"noTrending": "No trending collections yet",
"noRecent": "No public collections yet",
"exportPdf": "Export as PDF", "exportPdf": "Export as PDF",
"shareTitle": "Share collection", "shareTitle": "Share collection",
"shareDescription": "Invite people to view or edit this collection.", "shareDescription": "Invite people to view or edit this collection.",
+9
View File
@@ -610,6 +610,8 @@
"title": "Garde-manger", "title": "Garde-manger",
"subtitle": "Suivez ce que vous avez en stock", "subtitle": "Suivez ce que vous avez en stock",
"canCook": "Que puis-je cuisiner ?", "canCook": "Que puis-je cuisiner ?",
"expiringSoonSuggestionsTitle": "Ă€ utiliser bientĂ´t",
"seeAll": "Tout voir",
"itemNamePlaceholder": "Nom de l'article", "itemNamePlaceholder": "Nom de l'article",
"qtyPlaceholder": "Qté", "qtyPlaceholder": "Qté",
"unitPlaceholder": "Unité", "unitPlaceholder": "Unité",
@@ -679,6 +681,13 @@
"public": "Publique", "public": "Publique",
"recipeCount": "{count} recette", "recipeCount": "{count} recette",
"recipeCountPlural": "{count} recettes", "recipeCountPlural": "{count} recettes",
"discoverTitle": "Découvrir",
"discoverSubtitle": "Collections publiques tendances et récemment partagées",
"myCollectionsLink": "Mes collections",
"trendingThisWeek": "Tendance cette semaine",
"recentlyAdded": "Récemment ajoutées",
"noTrending": "Aucune collection tendance pour l'instant",
"noRecent": "Aucune collection publique pour l'instant",
"exportPdf": "Exporter en PDF", "exportPdf": "Exporter en PDF",
"shareTitle": "Partager la collection", "shareTitle": "Partager la collection",
"shareDescription": "Invitez des personnes Ă  voir ou modifier cette collection.", "shareDescription": "Invitez des personnes Ă  voir ou modifier cette collection.",
@@ -0,0 +1,10 @@
CREATE TABLE "collection_favorites" (
"user_id" text NOT NULL,
"collection_id" text NOT NULL,
"created_at" timestamp DEFAULT now() NOT NULL
);
--> statement-breakpoint
ALTER TABLE "collection_favorites" ADD CONSTRAINT "collection_favorites_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "collection_favorites" ADD CONSTRAINT "collection_favorites_collection_id_collections_id_fk" FOREIGN KEY ("collection_id") REFERENCES "public"."collections"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "collection_favorites_collection_idx" ON "collection_favorites" USING btree ("collection_id");--> statement-breakpoint
CREATE UNIQUE INDEX "collection_favorites_user_collection_idx" ON "collection_favorites" USING btree ("user_id","collection_id");
File diff suppressed because it is too large Load Diff
@@ -155,6 +155,13 @@
"when": 1783602591480, "when": 1783602591480,
"tag": "0021_mysterious_madame_masque", "tag": "0021_mysterious_madame_masque",
"breakpoints": true "breakpoints": true
},
{
"idx": 22,
"version": "7",
"when": 1783603737477,
"tag": "0022_foamy_loki",
"breakpoints": true
} }
] ]
} }
+15
View File
@@ -6,6 +6,7 @@ import {
boolean, boolean,
pgEnum, pgEnum,
index, index,
uniqueIndex,
} from "drizzle-orm/pg-core"; } from "drizzle-orm/pg-core";
import { relations } from "drizzle-orm"; import { relations } from "drizzle-orm";
import { users } from "./users"; import { users } from "./users";
@@ -72,6 +73,15 @@ export const collectionRecipes = pgTable("collection_recipes", {
addedAt: timestamp("added_at").notNull().defaultNow(), addedAt: timestamp("added_at").notNull().defaultNow(),
}); });
export const collectionFavorites = pgTable("collection_favorites", {
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
collectionId: text("collection_id").notNull().references(() => collections.id, { onDelete: "cascade" }),
createdAt: timestamp("created_at").notNull().defaultNow(),
}, (t) => [
index("collection_favorites_collection_idx").on(t.collectionId),
uniqueIndex("collection_favorites_user_collection_idx").on(t.userId, t.collectionId),
]);
export const cookingHistory = pgTable("cooking_history", { export const cookingHistory = pgTable("cooking_history", {
id: text("id").primaryKey(), id: text("id").primaryKey(),
userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }), userId: text("user_id").notNull().references(() => users.id, { onDelete: "cascade" }),
@@ -117,6 +127,11 @@ export const collectionRecipesRelations = relations(collectionRecipes, ({ one })
recipe: one(recipes, { fields: [collectionRecipes.recipeId], references: [recipes.id] }), recipe: one(recipes, { fields: [collectionRecipes.recipeId], references: [recipes.id] }),
})); }));
export const collectionFavoritesRelations = relations(collectionFavorites, ({ one }) => ({
collection: one(collections, { fields: [collectionFavorites.collectionId], references: [collections.id] }),
user: one(users, { fields: [collectionFavorites.userId], references: [users.id] }),
}));
export const collectionMemberRoleEnum = pgEnum("collection_member_role", ["viewer", "editor"]); export const collectionMemberRoleEnum = pgEnum("collection_member_role", ["viewer", "editor"]);
export const collectionMembers = pgTable("collection_members", { export const collectionMembers = pgTable("collection_members", {