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:
53
src/app/api/recipes/[id]/promote/route.ts
Normal file
53
src/app/api/recipes/[id]/promote/route.ts
Normal 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 }
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -18,7 +18,9 @@ export async function GET() {
|
|||||||
orderBy: { createdAt: "desc" },
|
orderBy: { createdAt: "desc" },
|
||||||
include: {
|
include: {
|
||||||
sourceDrink: {
|
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 },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
"use client"
|
"use client"
|
||||||
|
|
||||||
import { useState } from "react"
|
import { useState } from "react"
|
||||||
|
import Link from "next/link"
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"
|
||||||
import { Badge } from "@/components/ui/badge"
|
import { Badge } from "@/components/ui/badge"
|
||||||
import { Button } from "@/components/ui/button"
|
import { Button } from "@/components/ui/button"
|
||||||
@@ -13,6 +14,8 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
GlassWater,
|
GlassWater,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Star,
|
||||||
|
Clock,
|
||||||
} from "lucide-react"
|
} from "lucide-react"
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from "@/lib/utils"
|
||||||
import type { RecipeIngredient } from "@/hooks/use-bartender"
|
import type { RecipeIngredient } from "@/hooks/use-bartender"
|
||||||
@@ -26,15 +29,20 @@ export interface RecipeCardData {
|
|||||||
glassware?: string | null
|
glassware?: string | null
|
||||||
notes?: string | null
|
notes?: string | null
|
||||||
missingCount?: number
|
missingCount?: number
|
||||||
sourceDrink?: { name: string; type: string } | null
|
sourceDrink?: { id: string; name: string; type: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type PromoteTarget = "drink" | "wishlist"
|
||||||
|
|
||||||
interface RecipeCardProps {
|
interface RecipeCardProps {
|
||||||
recipe: RecipeCardData
|
recipe: RecipeCardData
|
||||||
onSave?: (recipe: RecipeCardData) => void
|
onSave?: (recipe: RecipeCardData) => void
|
||||||
onDelete?: (id: string) => void
|
onDelete?: (id: string) => void
|
||||||
|
onPromote?: (id: string, target: PromoteTarget) => void
|
||||||
isSaving?: boolean
|
isSaving?: boolean
|
||||||
isDeleting?: boolean
|
isDeleting?: boolean
|
||||||
|
/** Which promotion is in flight for this card, if any. */
|
||||||
|
promoting?: PromoteTarget | null
|
||||||
saved?: boolean
|
saved?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -42,8 +50,10 @@ export function RecipeCard({
|
|||||||
recipe,
|
recipe,
|
||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
|
onPromote,
|
||||||
isSaving,
|
isSaving,
|
||||||
isDeleting,
|
isDeleting,
|
||||||
|
promoting,
|
||||||
saved,
|
saved,
|
||||||
}: RecipeCardProps) {
|
}: RecipeCardProps) {
|
||||||
const [expanded, setExpanded] = useState(false)
|
const [expanded, setExpanded] = useState(false)
|
||||||
@@ -189,6 +199,64 @@ export function RecipeCard({
|
|||||||
<p className="text-sm text-muted-foreground">{recipe.notes}</p>
|
<p className="text-sm text-muted-foreground">{recipe.notes}</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/*
|
||||||
|
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 && (
|
||||||
|
<div className="flex flex-wrap gap-2 border-t pt-3">
|
||||||
|
{recipe.sourceDrink ? (
|
||||||
|
<Button asChild variant="secondary" size="sm">
|
||||||
|
<Link href={`/rate/${recipe.sourceDrink.id}`}>
|
||||||
|
<Star className="mr-1.5 h-4 w-4" />
|
||||||
|
Rate this
|
||||||
|
</Link>
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
disabled={!!promoting}
|
||||||
|
onClick={() => onPromote(recipe.id!, "drink")}
|
||||||
|
>
|
||||||
|
{promoting === "drink" ? (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<GlassWater className="mr-1.5 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Add to my drinks
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
disabled={!!promoting}
|
||||||
|
onClick={() => onPromote(recipe.id!, "wishlist")}
|
||||||
|
>
|
||||||
|
{promoting === "wishlist" ? (
|
||||||
|
<Loader2 className="mr-1.5 h-4 w-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Clock className="mr-1.5 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
Try later
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{recipe.sourceDrink && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
In your drinks as{" "}
|
||||||
|
<Link
|
||||||
|
href={`/drinks/${recipe.sourceDrink.id}`}
|
||||||
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
|
>
|
||||||
|
{recipe.sourceDrink.name}
|
||||||
|
</Link>
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,15 +1,27 @@
|
|||||||
"use client"
|
"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 { 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() {
|
export function SavedRecipesTab() {
|
||||||
|
const router = useRouter()
|
||||||
const { data, isLoading, error } = useRecipes()
|
const { data, isLoading, error } = useRecipes()
|
||||||
const deleteRecipe = useDeleteRecipe()
|
const deleteRecipe = useDeleteRecipe()
|
||||||
|
const promoteRecipe = usePromoteRecipe()
|
||||||
const [deletingId, setDeletingId] = useState<string | null>(null)
|
const [deletingId, setDeletingId] = useState<string | null>(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<string | null>(null)
|
||||||
|
const [promoteError, setPromoteError] = useState<string | null>(null)
|
||||||
|
|
||||||
const recipes = data?.recipes || []
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
@@ -58,25 +100,46 @@ export function SavedRecipesTab() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="space-y-4">
|
||||||
{recipes.map((recipe) => (
|
{notice && (
|
||||||
<RecipeCard
|
<div className="rounded-md bg-primary/10 p-3 text-sm">
|
||||||
key={recipe.id}
|
{notice}{" "}
|
||||||
recipe={{
|
<Link
|
||||||
id: recipe.id,
|
href="/wishlist"
|
||||||
title: recipe.title,
|
className="text-primary underline-offset-4 hover:underline"
|
||||||
ingredients: recipe.ingredients,
|
>
|
||||||
steps: recipe.steps,
|
View wishlist
|
||||||
garnish: recipe.garnish,
|
</Link>
|
||||||
glassware: recipe.glassware,
|
</div>
|
||||||
notes: recipe.notes,
|
)}
|
||||||
sourceDrink: recipe.sourceDrink,
|
{promoteError && (
|
||||||
}}
|
<div className="rounded-md bg-destructive/10 p-3">
|
||||||
onDelete={handleDelete}
|
<p className="text-sm text-destructive">{promoteError}</p>
|
||||||
isDeleting={deletingId === recipe.id}
|
</div>
|
||||||
saved
|
)}
|
||||||
/>
|
|
||||||
))}
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
{recipes.map((recipe) => (
|
||||||
|
<RecipeCard
|
||||||
|
key={recipe.id}
|
||||||
|
recipe={{
|
||||||
|
id: recipe.id,
|
||||||
|
title: recipe.title,
|
||||||
|
ingredients: recipe.ingredients,
|
||||||
|
steps: recipe.steps,
|
||||||
|
garnish: recipe.garnish,
|
||||||
|
glassware: recipe.glassware,
|
||||||
|
notes: recipe.notes,
|
||||||
|
sourceDrink: recipe.sourceDrink,
|
||||||
|
}}
|
||||||
|
onDelete={handleDelete}
|
||||||
|
onPromote={handlePromote}
|
||||||
|
isDeleting={deletingId === recipe.id}
|
||||||
|
promoting={promoting?.id === recipe.id ? promoting.target : null}
|
||||||
|
saved
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ export interface Recipe {
|
|||||||
notes: string | null
|
notes: string | null
|
||||||
createdAt: string
|
createdAt: string
|
||||||
updatedAt: string
|
updatedAt: string
|
||||||
sourceDrink?: { name: string; type: string } | null
|
sourceDrink?: { id: string; name: string; type: string } | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SuggestedCocktail {
|
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<PromotionResponse> =>
|
||||||
|
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"],
|
||||||
|
})
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|||||||
@@ -120,6 +120,20 @@ export const saveRecipeSchema = recipeCreateSchema
|
|||||||
|
|
||||||
export const deleteRecipeSchema = z.object({ id: idField })
|
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 ────────────────────────────────────────────────────
|
// ─── Wishlist ────────────────────────────────────────────────────
|
||||||
|
|
||||||
export const listWishlistSchema = z.object({})
|
export const listWishlistSchema = z.object({})
|
||||||
|
|||||||
@@ -10,8 +10,10 @@ import { McpToolError } from "@/lib/mcp/context"
|
|||||||
import {
|
import {
|
||||||
deleteRecipeSchema,
|
deleteRecipeSchema,
|
||||||
listRecipesSchema,
|
listRecipesSchema,
|
||||||
|
promoteRecipeSchema,
|
||||||
saveRecipeSchema,
|
saveRecipeSchema,
|
||||||
} from "@/lib/mcp/schemas"
|
} from "@/lib/mcp/schemas"
|
||||||
|
import { promoteRecipe } from "@/lib/recipe-promotion"
|
||||||
import { formatRecipeLine, ok } from "@/lib/mcp/render"
|
import { formatRecipeLine, ok } from "@/lib/mcp/render"
|
||||||
|
|
||||||
type StoredIngredient = { name: string; amount: string; available: boolean }
|
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(
|
defineTool(
|
||||||
server,
|
server,
|
||||||
{
|
{
|
||||||
|
|||||||
122
src/lib/recipe-promotion.ts
Normal file
122
src/lib/recipe-promotion.ts
Normal file
@@ -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<PromotionResult> {
|
||||||
|
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 }
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user