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:
@@ -28,7 +28,7 @@ export type RecipeResult = {
|
||||
baseServings: number;
|
||||
prepMins: number | null;
|
||||
cookMins: number | null;
|
||||
visibility: "private" | "unlisted" | "public";
|
||||
visibility: "private" | "unlisted" | "public" | "followers";
|
||||
tags: string[];
|
||||
isBatchCook: boolean;
|
||||
sourceUrl: string | null;
|
||||
|
||||
@@ -3,7 +3,7 @@ import Image from "next/image";
|
||||
import { notFound } from "next/navigation";
|
||||
import { headers } from "next/headers";
|
||||
import Link from "next/link";
|
||||
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play } from "lucide-react";
|
||||
import { Clock, Users, Globe, Lock, Link2, Pencil, ChefHat, ExternalLink, Play, UserCheck } from "lucide-react";
|
||||
import { VariationsButton } from "@/components/recipe/variations-button";
|
||||
import { TranslateButton } from "@/components/recipe/translate-button";
|
||||
import { AddToShoppingListButton } from "@/components/recipe/add-to-shopping-list-button";
|
||||
@@ -19,7 +19,8 @@ import { NutritionPanel } from "@/components/recipe/nutrition-panel";
|
||||
import { GenerateContentButton } from "@/components/recipe/generate-content-button";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, ratings, favorites, recipeVariations, recipeNotes, cookingHistory, avg } from "@epicure/db";
|
||||
import { and, eq, or, count, inArray, desc } from "@epicure/db";
|
||||
import { and, eq, count, desc } from "@epicure/db";
|
||||
import { visibleToViewer } from "@/lib/visibility";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
@@ -50,13 +51,13 @@ export async function generateMetadata({ params }: Params): Promise<Metadata> {
|
||||
const recipe = await db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
|
||||
visibleToViewer(session.user.id)
|
||||
),
|
||||
});
|
||||
return { title: recipe?.title ?? "Recipe" };
|
||||
}
|
||||
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe };
|
||||
const VISIBILITY_ICON = { private: Lock, unlisted: Link2, public: Globe, followers: UserCheck };
|
||||
const DIFFICULTY_COLOR = { easy: "default", medium: "secondary", hard: "destructive" } as const;
|
||||
|
||||
export default async function RecipePage({ params }: Params) {
|
||||
@@ -73,7 +74,7 @@ export default async function RecipePage({ params }: Params) {
|
||||
db.query.recipes.findFirst({
|
||||
where: and(
|
||||
eq(recipes.id, id),
|
||||
or(eq(recipes.authorId, session.user.id), inArray(recipes.visibility, ["public", "unlisted"]))
|
||||
visibleToViewer(session.user.id)
|
||||
),
|
||||
with: {
|
||||
ingredients: { orderBy: (t, { asc }) => asc(t.order) },
|
||||
|
||||
@@ -48,8 +48,8 @@ export default async function RecipesPage({ searchParams }: { searchParams: Sear
|
||||
const page = Math.max(1, parseInt(pageParam ?? "1", 10) || 1);
|
||||
const offset = (page - 1) * PAGE_SIZE;
|
||||
|
||||
const visibilityFilter = visibility && ["private", "unlisted", "public"].includes(visibility)
|
||||
? (visibility as "private" | "unlisted" | "public")
|
||||
const visibilityFilter = visibility && ["private", "unlisted", "public", "followers"].includes(visibility)
|
||||
? (visibility as "private" | "unlisted" | "public" | "followers")
|
||||
: undefined;
|
||||
const difficultyFilter = difficulty && ["easy", "medium", "hard"].includes(difficulty)
|
||||
? (difficulty as "easy" | "medium" | "hard")
|
||||
|
||||
@@ -10,6 +10,7 @@ const PAGE_SIZE = 100;
|
||||
const VISIBILITY_COLORS = {
|
||||
public: "default",
|
||||
unlisted: "outline",
|
||||
followers: "outline",
|
||||
private: "secondary",
|
||||
} as const;
|
||||
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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(),
|
||||
});
|
||||
|
||||
@@ -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)];
|
||||
|
||||
@@ -28,9 +28,10 @@ export default async function RecipePrintPage({ params }: Params) {
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
// /r/[id] resolves for both "public" and "unlisted" (see app/r/[id]/page.tsx)
|
||||
// — only "private" has no scannable link to offer.
|
||||
const shareUrl = recipe.visibility !== "private"
|
||||
// /r/[id] resolves anonymously for "public" and "unlisted" only (see
|
||||
// app/r/[id]/page.tsx) — "private" and "followers" have no link a random
|
||||
// scanner of this QR code could actually open.
|
||||
const shareUrl = recipe.visibility === "public" || recipe.visibility === "unlisted"
|
||||
? `${process.env["BETTER_AUTH_URL"] ?? "http://localhost:3000"}/r/${id}`
|
||||
: null;
|
||||
const qrDataUrl = shareUrl ? await QRCode.toDataURL(shareUrl, { margin: 1, width: 120 }) : null;
|
||||
|
||||
@@ -7,6 +7,7 @@ import Link from "next/link";
|
||||
import { Clock, Users, ChefHat, Globe } from "lucide-react";
|
||||
import { auth } from "@/lib/auth/server";
|
||||
import { db, recipes, eq } from "@epicure/db";
|
||||
import { isFollowing } from "@/lib/social-follows";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { buttonVariants } from "@/components/ui/button";
|
||||
import { Separator } from "@/components/ui/separator";
|
||||
@@ -54,6 +55,16 @@ export default async function PublicRecipePage({ params }: Params) {
|
||||
|
||||
if (!recipe) notFound();
|
||||
|
||||
// "Followers only" recipes are excluded from the anonymous-accessible query
|
||||
// above only by being neither public nor unlisted — but ne("private") still
|
||||
// lets them through. This route has no session-based access control at all
|
||||
// otherwise, so gate them explicitly: the author, or a signed-in follower.
|
||||
if (recipe.visibility === "followers") {
|
||||
const viewerId = session?.user?.id;
|
||||
const allowed = !!viewerId && (viewerId === recipe.authorId || await isFollowing(viewerId, recipe.authorId));
|
||||
if (!allowed) notFound();
|
||||
}
|
||||
|
||||
const cover = recipe.photos.find((p) => p.isCover) ?? recipe.photos[0];
|
||||
const totalMins = (recipe.prepMins ?? 0) + (recipe.cookMins ?? 0);
|
||||
const activeTags = Object.entries(recipe.dietaryTags ?? {})
|
||||
|
||||
Reference in New Issue
Block a user