- 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>
101 lines
3.2 KiB
TypeScript
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 }
|
|
)
|
|
}
|
|
}
|