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:
@@ -1,5 +1,5 @@
|
||||
// Mirrors CHANGELOG.md at the repo root — update both together.
|
||||
export const APP_VERSION = "0.40.0";
|
||||
export const APP_VERSION = "0.41.0";
|
||||
|
||||
export type ChangelogEntry = {
|
||||
version: string;
|
||||
@@ -11,6 +11,14 @@ export type ChangelogEntry = {
|
||||
};
|
||||
|
||||
export const CHANGELOG: ChangelogEntry[] = [
|
||||
{
|
||||
version: "0.41.0",
|
||||
date: "2026-07-17 14:45",
|
||||
added: [
|
||||
"New recipe visibility option: \"Followers only\" — visible to people who follow you, hidden from public browsing, search, and anonymous share links. Available in the recipe editor, bulk visibility actions, and the Recipes filter.",
|
||||
],
|
||||
notes: "Recipes you already own show up in your own profile/recipe list regardless of visibility, as before. Public profile pages and search results don't yet surface a viewer's followers-only recipes from that author — those remain reachable only via the recipe's direct link or the Following feed.",
|
||||
},
|
||||
{
|
||||
version: "0.40.0",
|
||||
date: "2026-07-17 14:00",
|
||||
|
||||
+10
-10
@@ -58,7 +58,7 @@ export function generateOpenApiSpec(): object {
|
||||
|
||||
const RecipeRef = registry.register("Recipe", z.object({
|
||||
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||
tags: z.array(z.string()), sourceUrl: z.string().nullable(), language: z.string().nullable(),
|
||||
@@ -72,7 +72,7 @@ export function generateOpenApiSpec(): object {
|
||||
|
||||
const RecipeSummaryRef = registry.register("RecipeSummary", z.object({
|
||||
id: z.string(), authorId: z.string(), title: z.string(), description: z.string().nullable(),
|
||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public"]),
|
||||
baseServings: z.number().int(), recipeType: z.enum(["dish", "drink"]), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||
prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||
tags: z.array(z.string()), dietaryTags: DietaryTagsRef, aiGenerated: z.boolean(),
|
||||
@@ -87,7 +87,7 @@ export function generateOpenApiSpec(): 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(),
|
||||
@@ -130,7 +130,7 @@ export function generateOpenApiSpec(): object {
|
||||
description: z.string().max(2000).nullable().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(),
|
||||
@@ -247,7 +247,7 @@ export function generateOpenApiSpec(): object {
|
||||
const idParam = z.object({ id: z.string() });
|
||||
const weekStartParam = z.object({ weekStart: z.string().describe("ISO date YYYY-MM-DD (Monday)") });
|
||||
|
||||
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/recipes", summary: "List your own recipes", description: "Returns a lean summary per recipe — no ingredients/steps/photos/batchDishes (use GET /recipes/{id} for full detail).", security, request: { query: z.object({ limit: z.coerce.number().max(100).default(20), offset: z.coerce.number().default(0), visibility: z.enum(["private", "unlisted", "public", "followers"]).optional(), recipeType: z.enum(["dish", "drink"]).optional() }) }, responses: { 200: { description: "Paginated", content: { "application/json": { schema: PaginatedRecipes } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "post", path: "/api/v1/recipes", summary: "Create recipe", security, request: { body: { content: { "application/json": { schema: CreateRecipeRef } }, required: true } }, responses: { 201: { description: "Created", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "get", path: "/api/v1/recipes/{id}", summary: "Get recipe", security, request: { params: idParam }, responses: { 200: { description: "Recipe", content: { "application/json": { schema: RecipeRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
registry.registerPath({ method: "put", path: "/api/v1/recipes/{id}", summary: "Update recipe", security, request: { params: idParam, body: { content: { "application/json": { schema: UpdateRecipeRef } }, required: true } }, responses: { 200: { description: "Updated", content: { "application/json": { schema: RecipeRef } } }, 400: { description: "Bad request", content: { "application/json": { schema: ApiErrorRef } } }, 401: { description: "Unauthorized", content: { "application/json": { schema: ApiErrorRef } } }, 404: { description: "Not found", content: { "application/json": { schema: ApiErrorRef } } } } });
|
||||
@@ -262,7 +262,7 @@ export function generateOpenApiSpec(): object {
|
||||
const BulkIdsRef = registry.register("BulkIds", z.object({ ids: z.array(z.string()).min(1).max(100) }));
|
||||
const BulkUpdateRecipesRef = registry.register("BulkUpdateRecipes", 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(),
|
||||
}));
|
||||
@@ -319,7 +319,7 @@ export function generateOpenApiSpec(): object {
|
||||
const FeedRecipeRef = registry.register("FeedRecipe", z.object({
|
||||
id: z.string(), title: z.string(), description: z.string().nullable(),
|
||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
||||
createdAt: z.string().datetime(), updatedAt: z.string().datetime(), authorId: z.string(),
|
||||
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
||||
@@ -597,7 +597,7 @@ export function generateOpenApiSpec(): object {
|
||||
const TrendingRecipeRef = registry.register("TrendingRecipe", z.object({
|
||||
id: z.string(), title: z.string(), description: z.string().nullable(),
|
||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
||||
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
||||
favoriteCount: z.number().int(),
|
||||
@@ -605,7 +605,7 @@ export function generateOpenApiSpec(): object {
|
||||
const ForYouRecipeRef = registry.register("ForYouRecipe", z.object({
|
||||
id: z.string(), title: z.string(), description: z.string().nullable(),
|
||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public"]),
|
||||
difficulty: z.enum(["easy", "medium", "hard"]).nullable(), visibility: z.enum(["private", "unlisted", "public", "followers"]),
|
||||
aiGenerated: z.boolean(), createdAt: z.string().datetime(), authorId: z.string(),
|
||||
authorName: z.string(), authorUsername: z.string().nullable(), authorAvatarUrl: z.string().nullable(),
|
||||
tags: z.array(z.string()), isBatchCook: z.boolean(), sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
||||
@@ -620,7 +620,7 @@ export function generateOpenApiSpec(): object {
|
||||
const SearchResultRef = registry.register("SearchResult", z.object({
|
||||
id: z.string(), title: z.string(), description: z.string().nullable(), difficulty: z.enum(["easy", "medium", "hard"]).nullable(),
|
||||
baseServings: z.number().int(), prepMins: z.number().int().nullable(), cookMins: z.number().int().nullable(),
|
||||
visibility: z.enum(["private", "unlisted", "public"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
|
||||
visibility: z.enum(["private", "unlisted", "public", "followers"]), tags: z.array(z.string()), isBatchCook: z.boolean(),
|
||||
sourceUrl: z.string().nullable(), recipeType: z.enum(["dish", "drink"]),
|
||||
authorId: z.string(), authorName: z.string(), createdAt: z.string().datetime(),
|
||||
avgRating: z.number().nullable(), ratingCount: z.number().int(),
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
import { db, userFollows, and, eq } from "@epicure/db";
|
||||
|
||||
/** True if `followerId` follows `followingId`. Used to gate "followers only"
|
||||
* visibility content — the same leftJoin+isNotNull idiom used elsewhere
|
||||
* (e.g. the for-you feed's private-author check) collapsed into a single
|
||||
* existence check for the single-recipe case. */
|
||||
export async function isFollowing(followerId: string, followingId: string): Promise<boolean> {
|
||||
if (followerId === followingId) return true;
|
||||
const [row] = await db
|
||||
.select({ followerId: userFollows.followerId })
|
||||
.from(userFollows)
|
||||
.where(and(eq(userFollows.followerId, followerId), eq(userFollows.followingId, followingId)))
|
||||
.limit(1);
|
||||
return !!row;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { recipes, userFollows, eq, or, and, inArray, sql } from "@epicure/db";
|
||||
|
||||
/**
|
||||
* Drizzle condition: true when `viewerId` may view a given recipe row — the
|
||||
* author, always; `public`/`unlisted` recipes, to anyone; `followers`-only
|
||||
* recipes, only to viewers who follow the author. Meant to be `and()`ed with
|
||||
* an `eq(recipes.id, id)` (or similar) in a single query, so the DB does the
|
||||
* filtering rather than fetching content the viewer can't see.
|
||||
*/
|
||||
export function visibleToViewer(viewerId: string) {
|
||||
return or(
|
||||
eq(recipes.authorId, viewerId),
|
||||
inArray(recipes.visibility, ["public", "unlisted"]),
|
||||
and(
|
||||
eq(recipes.visibility, "followers"),
|
||||
sql`exists (select 1 from ${userFollows} where ${userFollows.followerId} = ${viewerId} and ${userFollows.followingId} = ${recipes.authorId})`
|
||||
)
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user