From 9da57dd1d08231be1442376799086d08a76e5fc1 Mon Sep 17 00:00:00 2001 From: Arnaud Date: Sun, 19 Jul 2026 19:26:34 +0200 Subject: [PATCH] feat: collections visibility enum (matches recipes) + QR on collection PDF (v0.54.0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replaces collections.isPublic (boolean) with collections.visibility (private/unlisted/public/followers — same enum recipes use). Two-step migration (0050 adds+backfills, 0051 drops isPublic) since drizzle-kit's add+drop-in-one-diff rename heuristic needs an interactive prompt we can't satisfy here. New collectionVisibleToViewer(viewerId) in lib/visibility.ts mirrors the existing recipe helper (author always sees own; public/unlisted visible to anyone; followers-only via the same user_follows EXISTS pattern) — used by the collection detail page, its print view, fork, and favorite, replacing their old `or(isPublic, own)` checks. Create/edit collection dialogs get the same 4-option visibility select as the recipe form instead of a public/private checkbox. Collection PDF export now generates a QR code (qrcode, same as the recipe PDF) linking to /collections/{id}, shown only when visibility is public/unlisted — same "would an anonymous scanner actually resolve this" rule as the recipe QR. Co-Authored-By: Claude Sonnet 5 --- CHANGELOG.md | 6 + apps/web/app/(app)/collections/[id]/page.tsx | 14 +- .../app/(app)/collections/explore/page.tsx | 4 +- apps/web/app/(app)/collections/page.tsx | 2 +- .../api/v1/collections/[id]/favorite/route.ts | 5 +- .../app/api/v1/collections/[id]/fork/route.ts | 13 +- apps/web/app/api/v1/collections/[id]/route.ts | 6 +- apps/web/app/api/v1/collections/route.ts | 4 +- apps/web/app/print/collection/[id]/page.tsx | 26 +- .../collections/collections-page-content.tsx | 7 +- .../collections/edit-collection-dialog.tsx | 29 +- .../social/new-collection-button.tsx | 27 +- apps/web/lib/changelog.ts | 10 +- apps/web/lib/openapi.ts | 8 +- apps/web/lib/visibility.ts | 14 +- apps/web/messages/en.json | 2 + apps/web/messages/fr.json | 2 + apps/web/package.json | 2 +- package.json | 2 +- .../db/src/migrations/0050_stale_vargas.sql | 2 + .../src/migrations/0051_chief_karen_page.sql | 1 + .../db/src/migrations/meta/0050_snapshot.json | 5538 +++++++++++++++++ .../db/src/migrations/meta/0051_snapshot.json | 5531 ++++++++++++++++ packages/db/src/migrations/meta/_journal.json | 14 + packages/db/src/schema/social.ts | 4 +- 25 files changed, 11217 insertions(+), 56 deletions(-) create mode 100644 packages/db/src/migrations/0050_stale_vargas.sql create mode 100644 packages/db/src/migrations/0051_chief_karen_page.sql create mode 100644 packages/db/src/migrations/meta/0050_snapshot.json create mode 100644 packages/db/src/migrations/meta/0051_snapshot.json diff --git a/CHANGELOG.md b/CHANGELOG.md index 4e22838..6ef3d35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,12 @@ All notable changes to Epicure are documented here. This file is mirrored in-app at `/changelog` (and in the admin dashboard) via `apps/web/lib/changelog.ts` — update both together. +## 0.54.0 — 2026-07-19 17:05 + +### Added +- Collections now have the same visibility options as recipes — private, followers-only, unlisted, or public — replacing the old public/private toggle. Fork, favorite, and viewing a collection all respect the new followers-only option the same way recipes do. +- Collection PDF exports now include a QR code linking back to the collection, same as recipe PDFs. + ## 0.53.1 — 2026-07-19 16:10 ### Fixed diff --git a/apps/web/app/(app)/collections/[id]/page.tsx b/apps/web/app/(app)/collections/[id]/page.tsx index 383a48d..316974f 100644 --- a/apps/web/app/(app)/collections/[id]/page.tsx +++ b/apps/web/app/(app)/collections/[id]/page.tsx @@ -4,7 +4,8 @@ import { headers } from "next/headers"; import Link from "next/link"; import { Printer, UtensilsCrossed, StickyNote } from "lucide-react"; import { auth } from "@/lib/auth/server"; -import { db, collections, eq, and, or } from "@epicure/db"; +import { db, collections, eq, and } from "@epicure/db"; +import { collectionVisibleToViewer } from "@/lib/visibility"; import { RecipeGridCard } from "@/components/recipe/recipe-grid-card"; import { CollectionRecipesGrid } from "@/components/collections/collection-recipes-grid"; import { ForkCollectionButton } from "@/components/collections/fork-collection-button"; @@ -32,10 +33,7 @@ export default async function CollectionPage({ params }: Params) { 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)) - ), + where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)), with: { recipes: { orderBy: (t, { asc }) => asc(t.position), @@ -63,7 +61,7 @@ export default async function CollectionPage({ params }: Params) { )}

- {recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {col.isPublic ? "Public" : "Private"} + {recipeList.length} recipe{recipeList.length !== 1 ? "s" : ""} · {m.recipe.visibility[col.visibility]}

{isOwner && col.notes && (
@@ -103,11 +101,11 @@ export default async function CollectionPage({ params }: Params) { initialDescription={col.description} initialNotes={col.notes} initialTags={col.tags} - initialIsPublic={col.isPublic} + initialVisibility={col.visibility} /> )} {isOwner && } - {!isOwner && col.isPublic && ( + {!isOwner && (col.visibility === "public" || col.visibility === "unlisted") && ( )}
diff --git a/apps/web/app/(app)/collections/explore/page.tsx b/apps/web/app/(app)/collections/explore/page.tsx index b31a8a4..020f05c 100644 --- a/apps/web/app/(app)/collections/explore/page.tsx +++ b/apps/web/app/(app)/collections/explore/page.tsx @@ -49,7 +49,7 @@ export default async function ExploreCollectionsPage() { collectionFavorites, and(eq(collectionFavorites.collectionId, collections.id), gte(collectionFavorites.createdAt, sevenDaysAgo)) ) - .where(eq(collections.isPublic, true)) + .where(eq(collections.visibility, "public")) .groupBy(collections.id, users.id) .orderBy(desc(sql`count(${collectionFavorites.collectionId})`)) .limit(12); @@ -63,7 +63,7 @@ export default async function ExploreCollectionsPage() { }) .from(collections) .innerJoin(users, eq(collections.userId, users.id)) - .where(eq(collections.isPublic, true)) + .where(eq(collections.visibility, "public")) .orderBy(desc(collections.createdAt)) .limit(12); diff --git a/apps/web/app/(app)/collections/page.tsx b/apps/web/app/(app)/collections/page.tsx index afdc3f5..1df72cf 100644 --- a/apps/web/app/(app)/collections/page.tsx +++ b/apps/web/app/(app)/collections/page.tsx @@ -66,7 +66,7 @@ export default async function CollectionsPage({ name: col.name, description: col.description, tags: col.tags, - isPublic: col.isPublic, + visibility: col.visibility, recipeCount: countByCollection.get(col.id) ?? 0, thumbnails: col.recipes.flatMap((r) => { if (!r.recipe) return []; diff --git a/apps/web/app/api/v1/collections/[id]/favorite/route.ts b/apps/web/app/api/v1/collections/[id]/favorite/route.ts index 6e09281..23cc48a 100644 --- a/apps/web/app/api/v1/collections/[id]/favorite/route.ts +++ b/apps/web/app/api/v1/collections/[id]/favorite/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, collections, collectionFavorites, eq, and, or } from "@epicure/db"; +import { db, collections, collectionFavorites, eq, and } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; +import { collectionVisibleToViewer } from "@/lib/visibility"; type Params = { params: Promise<{ id: string }> }; @@ -10,7 +11,7 @@ export async function POST(req: NextRequest, { params }: Params) { 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))), + where: and(eq(collections.id, id), collectionVisibleToViewer(session!.user.id)), columns: { id: true }, }); if (!collection) return NextResponse.json({ error: "Not found" }, { status: 404 }); diff --git a/apps/web/app/api/v1/collections/[id]/fork/route.ts b/apps/web/app/api/v1/collections/[id]/fork/route.ts index a4845a5..07d3ffd 100644 --- a/apps/web/app/api/v1/collections/[id]/fork/route.ts +++ b/apps/web/app/api/v1/collections/[id]/fork/route.ts @@ -1,6 +1,7 @@ import { NextRequest, NextResponse } from "next/server"; -import { db, collections, collectionRecipes, eq, and, or } from "@epicure/db"; +import { db, collections, collectionRecipes, eq, and } from "@epicure/db"; import { requireSessionOrApiKey } from "@/lib/api-auth"; +import { collectionVisibleToViewer } from "@/lib/visibility"; type Params = { params: Promise<{ id: string }> }; @@ -9,12 +10,10 @@ export async function POST(req: NextRequest, { params }: Params) { if (response) return response; const { id } = await params; - // Allow forking public collections or own collections + // Allow forking any collection visible to this viewer (own, or public/ + // unlisted/followers-if-following) — never a private collection of someone else's. const source = await db.query.collections.findFirst({ - where: and( - eq(collections.id, id), - or(eq(collections.isPublic, true), eq(collections.userId, session!.user.id)) - ), + where: and(eq(collections.id, id), collectionVisibleToViewer(session!.user.id)), with: { recipes: { columns: { recipeId: true } } }, }); @@ -26,7 +25,7 @@ export async function POST(req: NextRequest, { params }: Params) { userId: session!.user.id, name: `${source.name} (fork)`, description: source.description, - isPublic: false, + visibility: "private", createdAt: new Date(), updatedAt: new Date(), }); diff --git a/apps/web/app/api/v1/collections/[id]/route.ts b/apps/web/app/api/v1/collections/[id]/route.ts index a4ba5d3..47d0ffb 100644 --- a/apps/web/app/api/v1/collections/[id]/route.ts +++ b/apps/web/app/api/v1/collections/[id]/route.ts @@ -36,7 +36,7 @@ export async function PUT(req: NextRequest, { params }: Params) { description: z.string().max(500).nullable().optional(), notes: z.string().max(2000).nullable().optional(), tags: z.array(z.string().min(1).max(50)).max(20).optional(), - isPublic: z.boolean().optional(), + visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(), addRecipeId: z.string().optional(), addRecipeIds: z.array(z.string()).max(200).optional(), removeRecipeId: z.string().optional(), @@ -45,13 +45,13 @@ export async function PUT(req: NextRequest, { params }: Params) { if (!parsed.success) return NextResponse.json({ error: "Validation error" }, { status: 400 }); const data = parsed.data; - if (data.name || data.description !== undefined || data.notes !== undefined || data.tags !== undefined || data.isPublic !== undefined) { + if (data.name || data.description !== undefined || data.notes !== undefined || data.tags !== undefined || data.visibility !== undefined) { await db.update(collections).set({ ...(data.name && { name: data.name }), ...(data.description !== undefined && { description: data.description }), ...(data.notes !== undefined && { notes: data.notes }), ...(data.tags !== undefined && { tags: data.tags }), - ...(data.isPublic !== undefined && { isPublic: data.isPublic }), + ...(data.visibility !== undefined && { visibility: data.visibility }), updatedAt: new Date(), }).where(eq(collections.id, id)); } diff --git a/apps/web/app/api/v1/collections/route.ts b/apps/web/app/api/v1/collections/route.ts index 772c824..c86c5f5 100644 --- a/apps/web/app/api/v1/collections/route.ts +++ b/apps/web/app/api/v1/collections/route.ts @@ -6,7 +6,7 @@ import { requireSessionOrApiKey } from "@/lib/api-auth"; const Schema = z.object({ name: z.string().min(1).max(100), description: z.string().max(500).optional(), - isPublic: z.boolean().default(false), + visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"), }); export async function GET(req: NextRequest) { @@ -61,7 +61,7 @@ export async function POST(req: NextRequest) { userId: session!.user.id, name: parsed.data.name, description: parsed.data.description, - isPublic: parsed.data.isPublic, + visibility: parsed.data.visibility, }); return NextResponse.json({ id }, { status: 201 }); diff --git a/apps/web/app/print/collection/[id]/page.tsx b/apps/web/app/print/collection/[id]/page.tsx index db24d7d..e6647f1 100644 --- a/apps/web/app/print/collection/[id]/page.tsx +++ b/apps/web/app/print/collection/[id]/page.tsx @@ -1,7 +1,9 @@ import { notFound } from "next/navigation"; import { headers } from "next/headers"; +import QRCode from "qrcode"; import { auth } from "@/lib/auth/server"; -import { db, collections, eq, and, or } from "@epicure/db"; +import { db, collections, eq, and } from "@epicure/db"; +import { collectionVisibleToViewer } from "@/lib/visibility"; import { PrintTrigger } from "@/components/recipe/print-trigger"; import { formatIngredientQuantity } from "@/lib/unit-conversion"; import { getMessages, formatMessage } from "@/lib/i18n/server"; @@ -17,10 +19,7 @@ export default async function CollectionPrintPage({ params }: Params) { const unitPref = (session.user as { unitPref?: string }).unitPref === "imperial" ? "imperial" : "metric"; const col = await db.query.collections.findFirst({ - where: and( - eq(collections.id, id), - or(eq(collections.userId, session.user.id), eq(collections.isPublic, true)) - ), + where: and(eq(collections.id, id), collectionVisibleToViewer(session.user.id)), with: { recipes: { with: { @@ -39,6 +38,13 @@ export default async function CollectionPrintPage({ params }: Params) { const recipeEntries = col.recipes.filter((r) => r.recipe !== null); + // Same rationale as the recipe print page's QR: only link a URL an + // anonymous scanner could actually resolve. + const shareUrl = col.visibility === "public" || col.visibility === "unlisted" + ? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/collections/${id}` + : null; + const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null; + return ( <>