Files
drinktracker/src/app/api/bartender/recreate/route.ts
JP Scott dc1ad4d0c0 Add recipes, images, AI photo ID, barcode scanning & ingredient matching
- Fuzzy ingredient matching for bar inventory against recipes
- AI photo identification API for bottles/labels (drink + bar context)
- Barcode scanner with photo toggle for My Bar
- Barcode scan + photo ID buttons on Add Drink form
- Auto-pull product images from Open Food Facts barcode lookup
- Recipes section on drink detail pages with bar availability
- Dedicated Recipes page in sidebar navigation
- Bar item image support (schema, upload, display)
- Drink detail image upload component
- MinIO image proxy through Next.js rewrites (fixes broken image links)
- Improved category mapping (energy drinks → Mixers, not Spirits)
- Re-process saved recipe ingredients against current bar inventory

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-04 22:26:17 -07:00

101 lines
3.2 KiB
TypeScript

import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "@/lib/ai/provider-factory"
import { rateLimit } from "@/lib/rate-limit"
import { COCKTAIL_RECIPE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
import { fuzzyMatchIngredients, recalculateMissingCount } from "@/lib/ingredient-matcher"
import { z } from "zod"
const recreateSchema = z.object({
cocktailName: z.string().min(1).max(200),
drinkId: z.string().optional(),
})
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { success: withinLimit } = rateLimit(`bartender-recreate:${session.user.id}`, 10, 60000)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
const body = await request.json()
const parsed = recreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
}
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId: session.user.id, isActive: true },
})
if (!apiKeyRecord) {
return NextResponse.json(
{ error: "No AI provider configured. Add an API key in Settings." },
{ status: 400 }
)
}
const barItems = await prisma.barItem.findMany({
where: {
userId: session.user.id,
quantity: { not: "EMPTY" },
},
select: { name: true, category: true, quantity: true },
})
const inventoryString = buildBarInventoryString(barItems)
const prompt = COCKTAIL_RECIPE_PROMPT.replace("{barInventory}", inventoryString)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const rawResponse = await provider.sendTextRequest(
prompt,
`Generate a recipe for: ${parsed.data.cocktailName}`
)
// Parse JSON from response
let recipe
try {
recipe = JSON.parse(rawResponse)
} catch {
// Try to extract from markdown code blocks or find JSON object
const codeBlockMatch = rawResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (codeBlockMatch) {
recipe = JSON.parse(codeBlockMatch[1].trim())
} else {
const objectMatch = rawResponse.match(/\{[\s\S]*\}/)
if (objectMatch) {
recipe = JSON.parse(objectMatch[0])
} else {
throw new Error("Could not parse recipe from AI response")
}
}
}
// Post-process: fuzzy-match ingredients against bar inventory
if (recipe.ingredients && Array.isArray(recipe.ingredients) && barItems.length > 0) {
recipe.ingredients = fuzzyMatchIngredients(recipe.ingredients, barItems)
recipe.missingCount = recalculateMissingCount(recipe.ingredients)
}
return NextResponse.json(recipe)
} catch (error) {
console.error("Bartender recreate error:", error)
return NextResponse.json(
{ error: "Failed to generate recipe. Please try again." },
{ status: 500 }
)
}
}