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:
243
src/app/(app)/bar/page.tsx
Normal file
243
src/app/(app)/bar/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
88
src/app/(app)/bartender/page.tsx
Normal file
88
src/app/(app)/bartender/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
93
src/app/(app)/recommend/page.tsx
Normal file
93
src/app/(app)/recommend/page.tsx
Normal 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>
|
||||
)
|
||||
}
|
||||
91
src/app/api/bar/[id]/route.ts
Normal file
91
src/app/api/bar/[id]/route.ts
Normal 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
60
src/app/api/bar/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
93
src/app/api/bartender/recreate/route.ts
Normal file
93
src/app/api/bartender/recreate/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
91
src/app/api/bartender/suggest/route.ts
Normal file
91
src/app/api/bartender/suggest/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
40
src/app/api/recipes/[id]/route.ts
Normal file
40
src/app/api/recipes/[id]/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
72
src/app/api/recipes/route.ts
Normal file
72
src/app/api/recipes/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
192
src/app/api/recommend/profile/route.ts
Normal file
192
src/app/api/recommend/profile/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
140
src/app/api/recommend/similar/route.ts
Normal file
140
src/app/api/recommend/similar/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
123
src/app/api/recommend/suggest/route.ts
Normal file
123
src/app/api/recommend/suggest/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user