Add a saved recipe to the collection or the wishlist so it can be rated

A recipe was previously a dead end: you could save one and see whether your bar
could make it, but there was no way to record that you had actually drunk it.

Promoting links rather than converts. A drink is created and Recipe.sourceDrinkId
is set to point at it, so the recipe survives and then renders on that drink's
page through the relation the drink detail view already reads. The wishlist path
creates a "try later" entry tagged source=recipe and leaves the recipe alone.

Both are idempotent. Promoting an already-linked recipe returns the existing
drink with a 200 rather than creating a second one, because pressing the button
twice is a double click and not a request for a duplicate. Creation and linking
happen in one transaction - a drink that existed but was not linked back would
show no recipe and would be promoted again on the next attempt.

The logic lives in src/lib/recipe-promotion.ts and is shared by the REST route
and the MCP tool, so the ownership check, the idempotency rule and the generated
description cannot drift apart.

Adding to drinks navigates straight to the rating page: being able to rate it is
the entire point, and an unrated cocktail is easy to lose in a list of 88. There
is no Toaster mounted in this app, so the wishlist path reports inline instead.

One MCP tool with a target parameter rather than two, since the tool list is
already at the point where selection quality suffers - 23 now. Gated on
drinks:write, not bar:write: it changes the collection, not the bar.

Verified: promote to drink then rate it, idempotency on both targets, the
recipe stays linked and undeleted, invalid target, unknown recipe, unauthenticated
access, read-only tokens, and that another user's recipe is refused identically
on both the REST route and the tool without confirming the row exists.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01W1Ee4Mc1X1SX8HgYa52zu7
This commit is contained in:
JP
2026-08-09 21:30:40 +00:00
parent 23c4e63a68
commit a8c959bfab
8 changed files with 430 additions and 26 deletions

View File

@@ -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 }
)
}