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 (
)
}
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 }
+}