Files
Arnaud 77c739960d feat: followers-only recipe visibility
Adds a fourth visibility tier alongside private/unlisted/public:
"followers" — visible to the author and anyone who follows them,
excluded from public search/explore/profile listings and from the
anonymous /r/[id] share route, included in the Following feed. The
in-app recipe detail gate and the public share route both check
follow status directly against the DB rather than the union type
alone, since neither previously had any per-viewer access logic for
a non-public/non-owner case.

Requires the generated migration (0041) to run against a live DB —
not applied in this sandbox (no Docker here); run `pnpm db:migrate`.

v0.41.0
2026-07-17 17:05:10 +02:00

68 lines
2.4 KiB
TypeScript

import { type NextRequest, NextResponse } from "next/server";
import { z } from "zod";
import { requireSessionOrApiKey } from "@/lib/api-auth";
import { db, recipes, eq, and, inArray } from "@epicure/db";
const BulkDeleteSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
});
const BulkUpdateSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
addTags: z.array(z.string().min(1).max(40)).max(20).optional(),
removeTags: z.array(z.string().min(1).max(40)).max(20).optional(),
});
export async function DELETE(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = BulkDeleteSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
await db
.delete(recipes)
.where(and(inArray(recipes.id, body.data.ids), eq(recipes.authorId, session!.user.id)));
return NextResponse.json({ ok: true });
}
export async function PATCH(req: NextRequest) {
const { session, response } = await requireSessionOrApiKey(req);
if (response) return response;
const body = BulkUpdateSchema.safeParse(await req.json());
if (!body.success) return NextResponse.json({ error: "Invalid request" }, { status: 400 });
const { ids, visibility, addTags, removeTags } = body.data;
if (!visibility && !addTags?.length && !removeTags?.length) {
return NextResponse.json({ error: "Nothing to update" }, { status: 400 });
}
if (visibility) {
await db
.update(recipes)
.set({ visibility, updatedAt: new Date() })
.where(and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)));
}
if (addTags?.length || removeTags?.length) {
const owned = await db.query.recipes.findMany({
where: and(inArray(recipes.id, ids), eq(recipes.authorId, session!.user.id)),
columns: { id: true, tags: true },
});
await Promise.all(
owned.map((r) => {
let nextTags = r.tags;
if (addTags?.length) nextTags = [...new Set([...nextTags, ...addTags])];
if (removeTags?.length) nextTags = nextTags.filter((tag) => !removeTags.includes(tag));
return db.update(recipes).set({ tags: nextTags, updatedAt: new Date() }).where(eq(recipes.id, r.id));
})
);
}
return NextResponse.json({ ok: true });
}