diff --git a/src/app/api/recipes/[id]/promote/route.ts b/src/app/api/recipes/[id]/promote/route.ts new file mode 100644 index 0000000..18f33e8 --- /dev/null +++ b/src/app/api/recipes/[id]/promote/route.ts @@ -0,0 +1,53 @@ +import { NextResponse } from "next/server" +import { z } from "zod" +import { requireUser } from "@/lib/authz" +import { promoteRecipe } from "@/lib/recipe-promotion" + +/** + * Promote a saved recipe into the collection so it can be rated, or onto the + * wishlist to try later. + * + * All the logic lives in src/lib/recipe-promotion.ts, shared with the MCP tool. + */ +const bodySchema = z.object({ + target: z.enum(["drink", "wishlist"]), +}) + +export async function POST( + request: Request, + { params }: { params: { id: string } } +) { + const session = await requireUser() + if (session instanceof NextResponse) return session + + const parsed = bodySchema.safeParse(await request.json().catch(() => null)) + if (!parsed.success) { + return NextResponse.json( + { error: "target must be \"drink\" or \"wishlist\"" }, + { status: 400 } + ) + } + + const result = await promoteRecipe( + session.user.id, + params.id, + parsed.data.target + ) + + if (result.kind === "not_found") { + return NextResponse.json({ error: "Not found" }, { status: 404 }) + } + + if (result.kind === "drink") { + return NextResponse.json( + { target: "drink", drink: result.drink, existing: result.existing }, + // 200 rather than 201 when it was already linked - nothing was created. + { status: result.existing ? 200 : 201 } + ) + } + + return NextResponse.json( + { target: "wishlist", item: result.item, existing: result.existing }, + { status: result.existing ? 200 : 201 } + ) +} diff --git a/src/app/api/recipes/route.ts b/src/app/api/recipes/route.ts index 42b4d79..8730af5 100644 --- a/src/app/api/recipes/route.ts +++ b/src/app/api/recipes/route.ts @@ -18,7 +18,9 @@ export async function GET() { orderBy: { createdAt: "desc" }, include: { sourceDrink: { - select: { name: true, type: true }, + // id included so the UI can link straight to the drink (and its + // rating page) for a recipe that has already been promoted. + select: { id: true, name: true, type: true }, }, }, }), diff --git a/src/components/bartender/recipe-card.tsx b/src/components/bartender/recipe-card.tsx index fa8bd13..bbdfce0 100644 --- a/src/components/bartender/recipe-card.tsx +++ b/src/components/bartender/recipe-card.tsx @@ -1,6 +1,7 @@ "use client" import { useState } from "react" +import Link from "next/link" import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card" import { Badge } from "@/components/ui/badge" import { Button } from "@/components/ui/button" @@ -13,6 +14,8 @@ import { Trash2, GlassWater, Loader2, + Star, + Clock, } from "lucide-react" import { cn } from "@/lib/utils" import type { RecipeIngredient } from "@/hooks/use-bartender" @@ -26,15 +29,20 @@ export interface RecipeCardData { glassware?: string | null notes?: string | null missingCount?: number - sourceDrink?: { name: string; type: string } | null + sourceDrink?: { id: string; name: string; type: string } | null } +export type PromoteTarget = "drink" | "wishlist" + interface RecipeCardProps { recipe: RecipeCardData onSave?: (recipe: RecipeCardData) => void onDelete?: (id: string) => void + onPromote?: (id: string, target: PromoteTarget) => void isSaving?: boolean isDeleting?: boolean + /** Which promotion is in flight for this card, if any. */ + promoting?: PromoteTarget | null saved?: boolean } @@ -42,8 +50,10 @@ export function RecipeCard({ recipe, onSave, onDelete, + onPromote, isSaving, isDeleting, + promoting, saved, }: RecipeCardProps) { const [expanded, setExpanded] = useState(false) @@ -189,6 +199,64 @@ export function RecipeCard({

{recipe.notes}

)} + + {/* + A recipe is instructions; a drink is the thing you have an opinion + about. Promoting links the two rather than converting, so the recipe + stays here and also shows on the drink's page. + */} + {saved && recipe.id && onPromote && ( +
+ {recipe.sourceDrink ? ( + + ) : ( + + )} + + +
+ )} + + {recipe.sourceDrink && ( +

+ In your drinks as{" "} + + {recipe.sourceDrink.name} + +

+ )} ) diff --git a/src/components/bartender/saved-recipes-tab.tsx b/src/components/bartender/saved-recipes-tab.tsx index 6fc0b56..0dad720 100644 --- a/src/components/bartender/saved-recipes-tab.tsx +++ b/src/components/bartender/saved-recipes-tab.tsx @@ -1,15 +1,27 @@ "use client" -import { Skeleton } from "@/components/ui/skeleton" -import { RecipeCard } from "./recipe-card" -import { useRecipes, useDeleteRecipe } from "@/hooks/use-bartender" -import { BookOpen } from "lucide-react" import { useState } from "react" +import Link from "next/link" +import { useRouter } from "next/navigation" +import { Skeleton } from "@/components/ui/skeleton" +import { RecipeCard, type PromoteTarget } from "./recipe-card" +import { + useRecipes, + useDeleteRecipe, + usePromoteRecipe, +} from "@/hooks/use-bartender" +import { BookOpen } from "lucide-react" export function SavedRecipesTab() { + const router = useRouter() const { data, isLoading, error } = useRecipes() const deleteRecipe = useDeleteRecipe() + const promoteRecipe = usePromoteRecipe() const [deletingId, setDeletingId] = useState(null) + const [promoting, setPromoting] = useState<{ id: string; target: PromoteTarget } | null>(null) + // There is no Toaster mounted in this app, so feedback is inline. + const [notice, setNotice] = useState(null) + const [promoteError, setPromoteError] = useState(null) const recipes = data?.recipes || [] @@ -23,6 +35,36 @@ export function SavedRecipesTab() { }) } + function handlePromote(id: string, target: PromoteTarget) { + setNotice(null) + setPromoteError(null) + setPromoting({ id, target }) + + promoteRecipe.mutate( + { id, target }, + { + onSuccess: (result) => { + if (result.target === "drink" && result.drink) { + // Straight to the rating page - being able to rate it is the whole + // point of adding it, and the drink is otherwise unrated and easy + // to lose track of. + router.push(`/rate/${result.drink.id}`) + return + } + setNotice( + result.existing + ? `${result.item?.name} is already on your wishlist.` + : `Added ${result.item?.name} to your wishlist.` + ) + }, + onError: (err: Error) => { + setPromoteError(err.message || "Could not add that. Please try again.") + }, + onSettled: () => setPromoting(null), + } + ) + } + if (isLoading) { return (
@@ -58,25 +100,46 @@ export function SavedRecipesTab() { } return ( -
- {recipes.map((recipe) => ( - - ))} +
+ {notice && ( +
+ {notice}{" "} + + View wishlist + +
+ )} + {promoteError && ( +
+

{promoteError}

+
+ )} + +
+ {recipes.map((recipe) => ( + + ))} +
) } diff --git a/src/hooks/use-bartender.ts b/src/hooks/use-bartender.ts index 62ee32f..3017a6d 100644 --- a/src/hooks/use-bartender.ts +++ b/src/hooks/use-bartender.ts @@ -21,7 +21,7 @@ export interface Recipe { notes: string | null createdAt: string updatedAt: string - sourceDrink?: { name: string; type: string } | null + sourceDrink?: { id: string; name: string; type: string } | null } export interface SuggestedCocktail { @@ -100,3 +100,41 @@ export function useDeleteRecipe() { }, }) } + +export interface PromotionResponse { + target: "drink" | "wishlist" + drink?: { id: string; name: string } + item?: { id: string; name: string } + /** True when it was already there - nothing new was created. */ + existing: boolean +} + +/** + * Adds a saved recipe to the collection (so it can be rated) or to the wishlist. + * The recipe itself is kept either way; promoting to a drink links the two. + */ +export function usePromoteRecipe() { + const queryClient = useQueryClient() + + return useMutation({ + mutationFn: ({ + id, + target, + }: { + id: string + target: "drink" | "wishlist" + }): Promise => + fetchWithError(`/api/recipes/${id}/promote`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ target }), + }), + onSuccess: (_data, variables) => { + // The recipe list carries the sourceDrink link, so it goes stale too. + queryClient.invalidateQueries({ queryKey: ["recipes"] }) + queryClient.invalidateQueries({ + queryKey: [variables.target === "drink" ? "drinks" : "wishlist"], + }) + }, + }) +} diff --git a/src/lib/mcp/schemas.ts b/src/lib/mcp/schemas.ts index 389a5c7..e109496 100644 --- a/src/lib/mcp/schemas.ts +++ b/src/lib/mcp/schemas.ts @@ -120,6 +120,20 @@ export const saveRecipeSchema = recipeCreateSchema export const deleteRecipeSchema = z.object({ id: idField }) +/** + * One tool with a target rather than two tools. The tool list is already at the + * point where selection quality starts to suffer, and "add it to my drinks" and + * "add it to try later" are the same gesture with a different destination. + */ +export const promoteRecipeSchema = z.object({ + id: idField, + target: z + .enum(["drink", "wishlist"]) + .describe( + "\"drink\" adds it to the collection so it can be rated; \"wishlist\" notes it to try later" + ), +}) + // ─── Wishlist ──────────────────────────────────────────────────── export const listWishlistSchema = z.object({}) diff --git a/src/lib/mcp/tools/recipes.ts b/src/lib/mcp/tools/recipes.ts index 2e39b7a..ae3491b 100644 --- a/src/lib/mcp/tools/recipes.ts +++ b/src/lib/mcp/tools/recipes.ts @@ -10,8 +10,10 @@ import { McpToolError } from "@/lib/mcp/context" import { deleteRecipeSchema, listRecipesSchema, + promoteRecipeSchema, saveRecipeSchema, } from "@/lib/mcp/schemas" +import { promoteRecipe } from "@/lib/recipe-promotion" import { formatRecipeLine, ok } from "@/lib/mcp/render" type StoredIngredient = { name: string; amount: string; available: boolean } @@ -132,6 +134,48 @@ export function registerRecipeTools(server: McpServer): void { } ) + defineTool( + server, + { + name: "promote_recipe", + title: "Add a recipe to drinks or the wishlist", + description: + "Turn a saved recipe into something the user can rate. target=\"drink\" adds it to their collection and links the recipe to it, so the recipe then shows on that drink's page; target=\"wishlist\" notes it to try later. The recipe is kept either way. Safe to call twice - if it is already there, the existing entry is returned rather than a duplicate created.", + inputSchema: promoteRecipeSchema, + // Creating a drink or wishlist entry is a change to the collection, not + // to the bar, so this is gated on drinks:write despite acting on a recipe. + scope: "drinks:write", + }, + async ({ id, target }, caller) => { + const result = await promoteRecipe(caller.userId, id, target) + + if (result.kind === "not_found") { + throw new McpToolError("No recipe with that id.", "not_found") + } + + if (result.kind === "drink") { + const verb = result.existing ? "was already in" : "added to" + return { + result: ok( + `[${result.drink.id}] ${result.drink.name} ${verb} the collection. Use rate_drink with this id to record what they thought.`, + { target: "drink", id: result.drink.id, existing: result.existing } + ), + recordId: result.drink.id, + } + } + + const verb = result.existing ? "was already on" : "added to" + return { + result: ok(`[${result.item.id}] ${result.item.name} ${verb} the wishlist.`, { + target: "wishlist", + id: result.item.id, + existing: result.existing, + }), + recordId: result.item.id, + } + } + ) + defineTool( server, { diff --git a/src/lib/recipe-promotion.ts b/src/lib/recipe-promotion.ts new file mode 100644 index 0000000..8ed3af6 --- /dev/null +++ b/src/lib/recipe-promotion.ts @@ -0,0 +1,122 @@ +import type { Drink, WishlistItem } from "@prisma/client" +import { prisma } from "@/lib/prisma" + +/** + * Turning a saved recipe into something rateable. + * + * A recipe is instructions; a Drink is the thing you form an opinion about. The + * two are related but not the same, so promoting **links** them via + * Recipe.sourceDrinkId rather than converting one into the other - the recipe + * survives, and it then renders on the drink's page through the relation the + * drink detail view already reads. + * + * This is deliberately shared between the REST route and the MCP tool. The + * ownership check, the idempotency rule and the generated description all have + * to be identical, and the wishlist promote route is a standing example of that + * logic existing in exactly one place and being copied by nobody. + */ + +export type PromotionTarget = "drink" | "wishlist" + +export type PromotionResult = + | { kind: "not_found" } + /** `existing` is true when the recipe was already linked - nothing was created. */ + | { kind: "drink"; drink: Drink; existing: boolean } + | { kind: "wishlist"; item: WishlistItem; existing: boolean } + +interface StoredIngredient { + name: string + amount: string + available: boolean +} + +function ingredientsOf(value: unknown): StoredIngredient[] { + return Array.isArray(value) ? (value as StoredIngredient[]) : [] +} + +/** + * A short human description, since a Drink has no ingredients of its own and a + * bare title would tell the user nothing on the drinks list a month from now. + */ +export function describeRecipe(recipe: { + ingredients: unknown + garnish: string | null + glassware: string | null + notes: string | null +}): string { + const names = ingredientsOf(recipe.ingredients).map((i) => i.name).filter(Boolean) + + const parts = [ + names.length ? `Made with ${names.join(", ")}.` : null, + recipe.glassware ? `Served in a ${recipe.glassware}.` : null, + recipe.garnish ? `Garnish: ${recipe.garnish}.` : null, + recipe.notes, + ].filter(Boolean) as string[] + + // description is capped at 2000 by drinkCreateSchema. + return parts.join(" ").slice(0, 2000) +} + +export async function promoteRecipe( + userId: string, + recipeId: string, + target: PromotionTarget +): Promise { + const recipe = await prisma.recipe.findFirst({ + where: { id: recipeId, userId }, + }) + if (!recipe) return { kind: "not_found" } + + const description = describeRecipe(recipe) + + if (target === "drink") { + // Already linked? Hand back the existing drink rather than creating a second + // one - promoting twice is a double click, not a request for a duplicate. + if (recipe.sourceDrinkId) { + const linked = await prisma.drink.findFirst({ + where: { id: recipe.sourceDrinkId, userId }, + }) + if (linked) return { kind: "drink", drink: linked, existing: true } + } + + // One transaction: a drink that exists but is not linked back would show no + // recipe on its page and would be promoted again on the next attempt. + const drink = await prisma.$transaction(async (tx) => { + const created = await tx.drink.create({ + data: { + userId, + name: recipe.title, + type: "COCKTAIL", + description: description || null, + }, + }) + await tx.recipe.update({ + where: { id: recipe.id }, + data: { sourceDrinkId: created.id }, + }) + return created + }) + + return { kind: "drink", drink, existing: false } + } + + // Wishlist. The recipe is untouched - "try later" is a note to self, not a + // change to the recipe itself. + const duplicate = await prisma.wishlistItem.findFirst({ + where: { userId, source: "recipe", name: { equals: recipe.title, mode: "insensitive" } }, + }) + if (duplicate) return { kind: "wishlist", item: duplicate, existing: true } + + const item = await prisma.wishlistItem.create({ + data: { + userId, + name: recipe.title, + type: "COCKTAIL", + description: description || null, + notes: recipe.notes, + source: "recipe", + }, + }) + + return { kind: "wishlist", item, existing: false } +}