Add My Bar, Bartender, Recommend features + drink images

- Drink Images: upload/display photos of bottles/cans on drink cards and detail pages
- My Bar: inventory tracker for spirits, liqueurs, mixers, bitters, garnishes, tools
- Bartender: AI-powered cocktail recipe generation, "what can I make" suggestions,
  saved recipes. Cross-references bar inventory for ingredient availability.
- Recommend: AI flavor profile analysis, personalized drink recommendations,
  "find similar" drinks based on highly-rated favorites
- Navigation: desktop sidebar with all 8 routes, mobile bottom nav with
  4 primary items + "More" popup menu
- New Prisma models: BarItem, Recipe, FlavorProfile
- Backup/restore updated to include bar items

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 18:28:02 -07:00
parent d8f069cce4
commit 2ac2c4b2d4
40 changed files with 3709 additions and 11 deletions

243
src/app/(app)/bar/page.tsx Normal file
View File

@@ -0,0 +1,243 @@
"use client"
import { Suspense, useState } from "react"
import { Header } from "@/components/layout/header"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
} from "@/components/ui/dialog"
import { BarItemForm } from "@/components/bar/bar-item-form"
import { BarCategoryGroup } from "@/components/bar/bar-category-group"
import {
useBarItems,
useCreateBarItem,
useUpdateBarItem,
useDeleteBarItem,
} from "@/hooks/use-bar"
import type { BarItem } from "@/hooks/use-bar"
import { Plus, Wine } from "lucide-react"
import type { BarItemCreate } from "@/lib/validators"
export default function BarPage() {
return (
<Suspense fallback={<BarLoading />}>
<BarContent />
</Suspense>
)
}
function BarLoading() {
return (
<div>
<Header title="My Bar" />
<div className="p-4 md:p-8 space-y-6">
<Skeleton className="h-8 w-48" />
<div className="space-y-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }, (_, j) => (
<Skeleton key={j} className="h-[120px] rounded-lg" />
))}
</div>
</div>
))}
</div>
</div>
</div>
)
}
const CATEGORY_ORDER = [
"SPIRITS",
"LIQUEURS",
"MIXERS",
"BITTERS",
"GARNISHES",
"TOOLS",
]
function BarContent() {
const [addDialogOpen, setAddDialogOpen] = useState(false)
const [editingItem, setEditingItem] = useState<BarItem | null>(null)
const { data, isLoading, error } = useBarItems()
const createBarItem = useCreateBarItem()
const updateBarItem = useUpdateBarItem()
const deleteBarItem = useDeleteBarItem()
function handleCreate(formData: BarItemCreate) {
createBarItem.mutate(formData, {
onSuccess: () => {
setAddDialogOpen(false)
},
})
}
function handleUpdate(formData: BarItemCreate) {
if (!editingItem) return
updateBarItem.mutate(
{ id: editingItem.id, data: formData },
{
onSuccess: () => {
setEditingItem(null)
},
}
)
}
function handleDelete(item: BarItem) {
if (confirm(`Delete "${item.name}" from your bar?`)) {
deleteBarItem.mutate(item.id)
}
}
// Group items by category
const groupedItems = (data?.items || []).reduce<Record<string, BarItem[]>>(
(groups, item) => {
const key = item.category
if (!groups[key]) groups[key] = []
groups[key].push(item)
return groups
},
{}
)
const totalItems = data?.items.length || 0
return (
<div>
<Header title="My Bar" />
<div className="p-4 md:p-8 space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold">My Bar</h1>
<p className="text-muted-foreground">
{totalItems > 0
? `${totalItems} item${totalItems !== 1 ? "s" : ""} in your bar`
: "Your bar inventory"}
</p>
</div>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Item
</Button>
</div>
{isLoading ? (
<div className="space-y-6">
{Array.from({ length: 3 }, (_, i) => (
<div key={i} className="space-y-3">
<Skeleton className="h-6 w-32" />
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-4">
{Array.from({ length: 4 }, (_, j) => (
<Skeleton key={j} className="h-[120px] rounded-lg" />
))}
</div>
</div>
))}
</div>
) : error ? (
<div className="text-center py-12">
<p className="text-destructive">
Failed to load bar items. Please try again.
</p>
</div>
) : totalItems === 0 ? (
<div className="text-center py-16 space-y-4">
<Wine className="h-12 w-12 mx-auto text-muted-foreground/50" />
<div>
<h3 className="text-lg font-semibold">Your bar is empty</h3>
<p className="text-muted-foreground mt-1">
Add your first item to get started.
</p>
</div>
<Button onClick={() => setAddDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
Add Your First Item
</Button>
</div>
) : (
<div className="space-y-8">
{CATEGORY_ORDER.filter((cat) => groupedItems[cat]?.length > 0).map(
(cat) => (
<BarCategoryGroup
key={cat}
category={cat}
items={groupedItems[cat]}
onEdit={setEditingItem}
onDelete={handleDelete}
/>
)
)}
</div>
)}
</div>
{/* Add Item Dialog */}
<Dialog open={addDialogOpen} onOpenChange={setAddDialogOpen}>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Add Bar Item</DialogTitle>
<DialogDescription>
Add a spirit, mixer, or other item to your bar inventory.
</DialogDescription>
</DialogHeader>
<BarItemForm
onSubmit={handleCreate}
isSubmitting={createBarItem.isPending}
submitLabel="Add Item"
/>
{createBarItem.isError && (
<p className="text-sm text-destructive">
{createBarItem.error.message || "Failed to add item"}
</p>
)}
</DialogContent>
</Dialog>
{/* Edit Item Dialog */}
<Dialog
open={editingItem !== null}
onOpenChange={(open) => {
if (!open) setEditingItem(null)
}}
>
<DialogContent className="max-h-[90vh] overflow-y-auto sm:max-w-[550px]">
<DialogHeader>
<DialogTitle>Edit Bar Item</DialogTitle>
<DialogDescription>
Update the details for this item.
</DialogDescription>
</DialogHeader>
{editingItem && (
<>
<BarItemForm
initialData={{
name: editingItem.name,
category: editingItem.category,
quantity: editingItem.quantity,
notes: editingItem.notes || undefined,
}}
onSubmit={handleUpdate}
isSubmitting={updateBarItem.isPending}
submitLabel="Update Item"
/>
{updateBarItem.isError && (
<p className="text-sm text-destructive">
{updateBarItem.error.message || "Failed to update item"}
</p>
)}
</>
)}
</DialogContent>
</Dialog>
</div>
)
}

View File

@@ -0,0 +1,88 @@
"use client"
import { Suspense, useState } from "react"
import { Header } from "@/components/layout/header"
import { Skeleton } from "@/components/ui/skeleton"
import { RecreateTab } from "@/components/bartender/recreate-tab"
import { SuggestTab } from "@/components/bartender/suggest-tab"
import { SavedRecipesTab } from "@/components/bartender/saved-recipes-tab"
import { Search, Sparkles, BookOpen } from "lucide-react"
import { cn } from "@/lib/utils"
export default function BartenderPage() {
return (
<Suspense fallback={<BartenderLoading />}>
<BartenderContent />
</Suspense>
)
}
function BartenderLoading() {
return (
<div>
<Header title="Bartender" />
<div className="p-4 md:p-8 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-10 w-full max-w-md" />
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{Array.from({ length: 4 }, (_, i) => (
<Skeleton key={i} className="h-[200px] rounded-lg" />
))}
</div>
</div>
</div>
)
}
type Tab = "recreate" | "suggest" | "saved"
const TABS: { id: Tab; label: string; icon: typeof Search }[] = [
{ id: "recreate", label: "Recreate", icon: Search },
{ id: "suggest", label: "Suggest", icon: Sparkles },
{ id: "saved", label: "Saved", icon: BookOpen },
]
function BartenderContent() {
const [activeTab, setActiveTab] = useState<Tab>("recreate")
return (
<div>
<Header title="Bartender" />
<div className="p-4 md:p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold">Bartender</h1>
<p className="text-muted-foreground">
AI-powered cocktail recipes based on your bar inventory.
</p>
</div>
{/* Tab switcher */}
<div className="flex gap-1 p-1 bg-muted rounded-lg max-w-md">
{TABS.map((tab) => {
const Icon = tab.icon
return (
<button
key={tab.id}
onClick={() => setActiveTab(tab.id)}
className={cn(
"flex-1 flex items-center justify-center gap-2 py-2 px-3 rounded-md text-sm font-medium transition-colors",
activeTab === tab.id
? "bg-background shadow-sm text-foreground"
: "text-muted-foreground hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
{tab.label}
</button>
)
})}
</div>
{/* Tab content */}
{activeTab === "recreate" && <RecreateTab />}
{activeTab === "suggest" && <SuggestTab />}
{activeTab === "saved" && <SavedRecipesTab />}
</div>
</div>
)
}

View File

@@ -74,6 +74,15 @@ export default async function DrinkDetailPage({
Back to Collection
</Link>
{/* Drink Image */}
{drink.imageUrl && (
<img
src={drink.imageUrl}
alt={drink.name}
className="w-full max-h-[400px] object-contain rounded-lg bg-muted"
/>
)}
{/* Main Info Card */}
<Card>
<CardHeader>

View File

@@ -0,0 +1,93 @@
"use client"
import { Suspense } from "react"
import { Header } from "@/components/layout/header"
import { Skeleton } from "@/components/ui/skeleton"
import { FlavorProfileCard } from "@/components/recommend/flavor-profile-card"
import { SuggestSection } from "@/components/recommend/suggest-section"
import { SimilarSection } from "@/components/recommend/similar-section"
import {
useFlavorProfile,
useGenerateFlavorProfile,
} from "@/hooks/use-recommend"
import { useDrinks } from "@/hooks/use-drinks"
import { Sparkles } from "lucide-react"
export default function RecommendPage() {
return (
<Suspense fallback={<RecommendLoading />}>
<RecommendContent />
</Suspense>
)
}
function RecommendLoading() {
return (
<div>
<Header title="Recommend" />
<div className="p-4 md:p-8 space-y-6">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-[200px] rounded-lg" />
<Skeleton className="h-[200px] rounded-lg" />
<Skeleton className="h-[200px] rounded-lg" />
</div>
</div>
)
}
function RecommendContent() {
const {
data: profileData,
isLoading: profileLoading,
error: profileError,
} = useFlavorProfile()
const generateProfile = useGenerateFlavorProfile()
const { data: drinksData, isLoading: drinksLoading } = useDrinks({
limit: 500,
sort: "name",
})
const profile = profileData?.profile ?? null
const hasProfile = !!profile
const drinkOptions = (drinksData?.drinks ?? []).map((d) => ({
id: d.id,
name: d.name,
type: d.type,
}))
return (
<div>
<Header title="Recommend" />
<div className="p-4 md:p-8 space-y-6">
<div>
<h1 className="text-2xl font-bold flex items-center gap-2">
<Sparkles className="h-6 w-6 text-primary" />
Recommendations
</h1>
<p className="text-muted-foreground mt-1">
AI-powered drink suggestions tailored to your taste.
</p>
</div>
<FlavorProfileCard
profile={profile}
isLoading={profileLoading}
isGenerating={generateProfile.isPending}
error={profileError}
generateError={generateProfile.error}
onGenerate={() => generateProfile.mutate()}
/>
<SuggestSection hasProfile={hasProfile} />
<SimilarSection
drinks={drinkOptions}
drinksLoading={drinksLoading}
/>
</div>
</div>
)
}

View File

@@ -0,0 +1,91 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { barItemUpdateSchema } from "@/lib/validators"
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Check ownership
const existing = await prisma.barItem.findUnique({
where: { id: params.id },
select: { userId: true },
})
if (!existing) {
return NextResponse.json({ error: "Bar item not found" }, { status: 404 })
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
const body = await request.json()
const parsed = barItemUpdateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
)
}
const item = await prisma.barItem.update({
where: { id: params.id },
data: parsed.data,
})
return NextResponse.json(item)
} catch (error) {
console.error("PUT /api/bar/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Check ownership
const existing = await prisma.barItem.findUnique({
where: { id: params.id },
select: { userId: true },
})
if (!existing) {
return NextResponse.json({ error: "Bar item not found" }, { status: 404 })
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await prisma.barItem.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("DELETE /api/bar/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

60
src/app/api/bar/route.ts Normal file
View File

@@ -0,0 +1,60 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { barItemCreateSchema } from "@/lib/validators"
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const items = await prisma.barItem.findMany({
where: { userId: session.user.id },
orderBy: [{ category: "asc" }, { name: "asc" }],
})
return NextResponse.json({ items })
} catch (error) {
console.error("GET /api/bar error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function POST(request: NextRequest) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
const parsed = barItemCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
)
}
const item = await prisma.barItem.create({
data: {
...parsed.data,
userId: session.user.id,
},
})
return NextResponse.json(item, { status: 201 })
} catch (error) {
console.error("POST /api/bar error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,93 @@
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 { 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")
}
}
}
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 }
)
}
}

View File

@@ -0,0 +1,91 @@
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 { WHAT_CAN_I_MAKE_PROMPT, buildBarInventoryString } from "@/lib/ai/prompts"
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { success: withinLimit } = rateLimit(`bartender-suggest:${session.user.id}`, 5, 60000)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
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 },
})
if (barItems.length === 0) {
return NextResponse.json(
{ error: "No items in your bar inventory. Add items to your bar first." },
{ status: 400 }
)
}
const inventoryString = buildBarInventoryString(barItems)
const prompt = WHAT_CAN_I_MAKE_PROMPT.replace("{barInventory}", inventoryString)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const rawResponse = await provider.sendTextRequest(
prompt,
"What cocktails can I make with my bar inventory?"
)
// Parse JSON from response
let suggestions
try {
suggestions = JSON.parse(rawResponse)
} catch {
const codeBlockMatch = rawResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (codeBlockMatch) {
suggestions = JSON.parse(codeBlockMatch[1].trim())
} else {
const arrayMatch = rawResponse.match(/\[[\s\S]*\]/)
if (arrayMatch) {
suggestions = JSON.parse(arrayMatch[0])
} else {
throw new Error("Could not parse suggestions from AI response")
}
}
}
if (!Array.isArray(suggestions)) {
suggestions = []
}
return NextResponse.json({ suggestions })
} catch (error) {
console.error("Bartender suggest error:", error)
return NextResponse.json(
{ error: "Failed to generate suggestions. Please try again." },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const existing = await prisma.recipe.findUnique({
where: { id: params.id },
select: { userId: true },
})
if (!existing) {
return NextResponse.json({ error: "Recipe not found" }, { status: 404 })
}
if (existing.userId !== session.user.id) {
return NextResponse.json({ error: "Forbidden" }, { status: 403 })
}
await prisma.recipe.delete({
where: { id: params.id },
})
return NextResponse.json({ success: true })
} catch (error) {
console.error("DELETE /api/recipes/[id] error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,72 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { recipeCreateSchema } from "@/lib/validators"
import type { Prisma } from "@prisma/client"
export async function GET() {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const recipes = await prisma.recipe.findMany({
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
include: {
sourceDrink: {
select: { name: true, type: true },
},
},
})
return NextResponse.json({ recipes })
} catch (error) {
console.error("GET /api/recipes error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}
export async function POST(request: Request) {
try {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const body = await request.json()
const parsed = recipeCreateSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Validation failed", issues: parsed.error.issues },
{ status: 400 }
)
}
const recipe = await prisma.recipe.create({
data: {
userId: session.user.id,
title: parsed.data.title,
ingredients: parsed.data.ingredients as unknown as Prisma.InputJsonValue,
steps: parsed.data.steps as unknown as Prisma.InputJsonValue,
garnish: parsed.data.garnish || null,
glassware: parsed.data.glassware || null,
sourceDrinkId: parsed.data.sourceDrinkId || null,
notes: parsed.data.notes || null,
},
})
return NextResponse.json(recipe, { status: 201 })
} catch (error) {
console.error("POST /api/recipes error:", error)
return NextResponse.json(
{ error: "Internal server error" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,192 @@
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 { FLAVOR_PROFILE_PROMPT, buildDrinkHistoryString } from "@/lib/ai/prompts"
import type { Prisma } from "@prisma/client"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const profile = await prisma.flavorProfile.findUnique({
where: { userId: session.user.id },
})
if (!profile) {
return NextResponse.json({ profile: null })
}
// Check staleness by comparing stored ratingCount to current count
const currentRatingCount = await prisma.rating.count({
where: { userId: session.user.id },
})
return NextResponse.json({
profile: {
id: profile.id,
profileText: profile.profileText,
profileData: profile.profileData,
generatedAt: profile.generatedAt,
ratingCount: profile.ratingCount,
isStale: currentRatingCount !== profile.ratingCount,
currentRatingCount,
},
})
} catch (error) {
console.error("Flavor profile fetch error:", error)
return NextResponse.json(
{ error: "Failed to fetch flavor profile." },
{ status: 500 }
)
}
}
export async function POST() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Rate limit: 5 profile generations per minute
const { success: withinLimit } = rateLimit(
`recommend-profile:${session.user.id}`,
5,
60 * 1000
)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
// Get user's AI provider
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 }
)
}
// Fetch all drinks with ratings
const drinks = await prisma.drink.findMany({
where: { userId: session.user.id },
include: {
ratings: {
where: { userId: session.user.id },
},
},
})
// Build drink summaries with computed avg rating
const drinkSummaries = drinks
.filter((d) => d.ratings.length > 0)
.map((d) => {
const avgRating =
d.ratings.reduce((sum, r) => sum + r.score, 0) / d.ratings.length
const wouldReorder = d.ratings.some((r) => r.wouldReorder)
return {
name: d.name,
type: d.type,
subType: d.subType,
brewery: d.brewery,
avgRating: Math.round(avgRating * 10) / 10,
ratingCount: d.ratings.length,
wouldReorder,
}
})
if (drinkSummaries.length < 3) {
return NextResponse.json(
{
error:
"You need at least 3 rated drinks to generate a flavor profile. Keep rating drinks!",
},
{ status: 400 }
)
}
const drinkHistory = buildDrinkHistoryString(drinkSummaries)
const prompt = FLAVOR_PROFILE_PROMPT.replace("{drinkHistory}", drinkHistory)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const rawResponse = await provider.sendTextRequest(
prompt,
"Analyze my drink history and build my flavor profile."
)
// Parse the JSON response
let profileData: Record<string, unknown>
try {
// Try direct parse
profileData = JSON.parse(rawResponse)
} catch {
// Try extracting from markdown code blocks
const match = rawResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (match) {
profileData = JSON.parse(match[1].trim())
} else {
const objectMatch = rawResponse.match(/\{[\s\S]*\}/)
if (objectMatch) {
profileData = JSON.parse(objectMatch[0])
} else {
throw new Error("Could not parse AI response as JSON")
}
}
}
const totalRatings = await prisma.rating.count({
where: { userId: session.user.id },
})
// Upsert the flavor profile
const profile = await prisma.flavorProfile.upsert({
where: { userId: session.user.id },
update: {
profileText:
(profileData.summary as string) || rawResponse.slice(0, 500),
profileData: profileData as Prisma.InputJsonValue,
generatedAt: new Date(),
ratingCount: totalRatings,
},
create: {
userId: session.user.id,
profileText:
(profileData.summary as string) || rawResponse.slice(0, 500),
profileData: profileData as Prisma.InputJsonValue,
ratingCount: totalRatings,
},
})
return NextResponse.json({
profile: {
id: profile.id,
profileText: profile.profileText,
profileData: profile.profileData,
generatedAt: profile.generatedAt,
ratingCount: profile.ratingCount,
isStale: false,
currentRatingCount: totalRatings,
},
})
} catch (error) {
console.error("Flavor profile generation error:", error)
return NextResponse.json(
{ error: "Failed to generate flavor profile. Please try again." },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,140 @@
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 { SIMILAR_DRINK_PROMPT } from "@/lib/ai/prompts"
import { z } from "zod"
const similarSchema = z.object({
drinkId: z.string().min(1),
})
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Rate limit: 10 similar requests per minute
const { success: withinLimit } = rateLimit(
`recommend-similar:${session.user.id}`,
10,
60 * 1000
)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
const body = await request.json()
const parsed = similarSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid request. Drink ID is required." },
{ status: 400 }
)
}
// Get user's AI provider
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 }
)
}
// Fetch the source drink
const drink = await prisma.drink.findFirst({
where: { id: parsed.data.drinkId, userId: session.user.id },
include: {
ratings: {
where: { userId: session.user.id },
orderBy: { createdAt: "desc" },
take: 5,
},
},
})
if (!drink) {
return NextResponse.json(
{ error: "Drink not found." },
{ status: 404 }
)
}
// Build source drink description
const avgRating =
drink.ratings.length > 0
? drink.ratings.reduce((sum, r) => sum + r.score, 0) /
drink.ratings.length
: null
const sourceDrinkParts = [`${drink.name} (${drink.type})`]
if (drink.subType) sourceDrinkParts.push(`Style: ${drink.subType}`)
if (drink.brewery) sourceDrinkParts.push(`From: ${drink.brewery}`)
if (drink.region) sourceDrinkParts.push(`Region: ${drink.region}`)
if (drink.abv) sourceDrinkParts.push(`ABV: ${drink.abv}%`)
if (drink.description) sourceDrinkParts.push(`Description: ${drink.description}`)
if (avgRating !== null)
sourceDrinkParts.push(
`User rating: ${(Math.round(avgRating * 10) / 10).toFixed(1)}/5`
)
const sourceDrink = sourceDrinkParts.join(" | ")
// Fetch flavor profile (optional for similar)
const profile = await prisma.flavorProfile.findUnique({
where: { userId: session.user.id },
})
const flavorProfile = profile?.profileText || "No flavor profile available."
const prompt = SIMILAR_DRINK_PROMPT
.replace("{sourceDrink}", sourceDrink)
.replace("{flavorProfile}", flavorProfile)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const rawResponse = await provider.sendTextRequest(
prompt,
`Find drinks similar to ${drink.name}.`
)
// Parse JSON response
let recommendations: unknown[]
try {
recommendations = JSON.parse(rawResponse)
} catch {
const match = rawResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (match) {
recommendations = JSON.parse(match[1].trim())
} else {
const arrayMatch = rawResponse.match(/\[[\s\S]*\]/)
if (arrayMatch) {
recommendations = JSON.parse(arrayMatch[0])
} else {
throw new Error("Could not parse AI response as JSON")
}
}
}
if (!Array.isArray(recommendations)) {
recommendations = []
}
return NextResponse.json({ recommendations, sourceDrink: drink.name })
} catch (error) {
console.error("Similar drink error:", error)
return NextResponse.json(
{ error: "Failed to find similar drinks. Please try again." },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,123 @@
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 { RECOMMEND_DRINK_PROMPT } from "@/lib/ai/prompts"
import { z } from "zod"
const suggestSchema = z.object({
mood: z.string().max(200).optional(),
occasion: z.string().max(200).optional(),
})
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
// Rate limit: 10 suggestions per minute
const { success: withinLimit } = rateLimit(
`recommend-suggest:${session.user.id}`,
10,
60 * 1000
)
if (!withinLimit) {
return NextResponse.json(
{ error: "Too many requests. Please wait a moment." },
{ status: 429 }
)
}
try {
const body = await request.json()
const parsed = suggestSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json({ error: "Invalid request" }, { status: 400 })
}
// Get user's AI provider
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 }
)
}
// Fetch flavor profile
const profile = await prisma.flavorProfile.findUnique({
where: { userId: session.user.id },
})
if (!profile) {
return NextResponse.json(
{
error:
"No flavor profile found. Generate your flavor profile first.",
},
{ status: 400 }
)
}
// Build context from mood/occasion
const contextParts: string[] = []
if (parsed.data.mood) {
contextParts.push(`Mood: ${parsed.data.mood}`)
}
if (parsed.data.occasion) {
contextParts.push(`Occasion: ${parsed.data.occasion}`)
}
const context =
contextParts.length > 0
? contextParts.join("\n")
: "No specific mood or occasion. Suggest a variety of drinks."
const prompt = RECOMMEND_DRINK_PROMPT
.replace("{flavorProfile}", profile.profileText)
.replace("{context}", context)
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
const rawResponse = await provider.sendTextRequest(
prompt,
"Recommend drinks for me based on my profile and the context provided."
)
// Parse JSON response
let recommendations: unknown[]
try {
recommendations = JSON.parse(rawResponse)
} catch {
const match = rawResponse.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (match) {
recommendations = JSON.parse(match[1].trim())
} else {
const arrayMatch = rawResponse.match(/\[[\s\S]*\]/)
if (arrayMatch) {
recommendations = JSON.parse(arrayMatch[0])
} else {
throw new Error("Could not parse AI response as JSON")
}
}
}
if (!Array.isArray(recommendations)) {
recommendations = []
}
return NextResponse.json({ recommendations })
} catch (error) {
console.error("Drink suggestion error:", error)
return NextResponse.json(
{ error: "Failed to get suggestions. Please try again." },
{ status: 500 }
)
}
}

View File

@@ -12,7 +12,7 @@ export async function GET() {
const userId = session.user.id
try {
const [drinks, ratings, wishlistItems, preferences, sharedLists] =
const [drinks, ratings, wishlistItems, preferences, sharedLists, barItems] =
await Promise.all([
prisma.drink.findMany({
where: { userId },
@@ -32,6 +32,10 @@ export async function GET() {
where: { userId },
orderBy: { createdAt: "asc" },
}),
prisma.barItem.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
])
const csv = generateBackupCsv(
@@ -39,7 +43,8 @@ export async function GET() {
ratings,
wishlistItems,
preferences,
sharedLists
sharedLists,
barItems
)
const date = new Date().toISOString().split("T")[0]