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
This commit is contained in:
Arnaud
2026-07-17 17:05:10 +02:00
parent 23babd4ee9
commit 77c739960d
29 changed files with 5183 additions and 43 deletions
+1 -1
View File
@@ -13,7 +13,7 @@ const UpdateRecipeSchema = z.object({
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).optional(),
recipeType: z.enum(["dish", "drink"]).optional(),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(),
difficulty: z.enum(["easy", "medium", "hard"]).nullable().optional(),
prepMins: z.number().int().min(0).max(1440).nullable().optional(),
cookMins: z.number().int().min(0).max(1440).nullable().optional(),
+1 -1
View File
@@ -9,7 +9,7 @@ const BulkDeleteSchema = z.object({
const BulkUpdateSchema = z.object({
ids: z.array(z.string()).min(1).max(100),
visibility: z.enum(["private", "unlisted", "public"]).optional(),
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(),
});
+2 -2
View File
@@ -14,7 +14,7 @@ const CreateRecipeSchema = z.object({
description: z.string().max(2000).optional(),
baseServings: z.number().int().min(1).max(100).default(4),
recipeType: z.enum(["dish", "drink"]).default("dish"),
visibility: z.enum(["private", "unlisted", "public"]).default("private"),
visibility: z.enum(["private", "unlisted", "public", "followers"]).default("private"),
difficulty: z.enum(["easy", "medium", "hard"]).optional(),
prepMins: z.number().int().min(0).max(1440).optional(),
cookMins: z.number().int().min(0).max(1440).optional(),
@@ -80,7 +80,7 @@ export async function GET(req: NextRequest) {
const { searchParams } = new URL(req.url);
const limit = Math.min(parseInt(searchParams.get("limit") ?? "20"), 100);
const offset = parseInt(searchParams.get("offset") ?? "0");
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | null;
const visibility = searchParams.get("visibility") as "private" | "unlisted" | "public" | "followers" | null;
const recipeType = searchParams.get("recipeType") as "dish" | "drink" | null;
const conditions = [eq(recipes.authorId, session!.user.id)];