diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 250aec4..de3c8b3 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -28,6 +28,9 @@ model User { preferences UserPreference? wishlistItems WishlistItem[] sharedLists SharedList[] + barItems BarItem[] + recipes Recipe[] + flavorProfile FlavorProfile? } model Account { @@ -123,6 +126,7 @@ model Drink { user User @relation(fields: [userId], references: [id], onDelete: Cascade) ratings Rating[] menuItems MenuItem[] + recipes Recipe[] @@index([userId]) @@index([userId, type]) @@ -249,3 +253,72 @@ model SharedList { @@index([userId]) @@index([slug]) } + +// ─── Bar Inventory ────────────────────────────────────────────── + +enum BarItemCategory { + SPIRITS + LIQUEURS + MIXERS + BITTERS + GARNISHES + TOOLS +} + +enum BarItemQuantity { + FULL + HALF + LOW + EMPTY +} + +model BarItem { + id String @id @default(cuid()) + userId String + name String + category BarItemCategory + quantity BarItemQuantity @default(FULL) + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + + @@index([userId]) + @@index([userId, category]) +} + +// ─── Recipes ──────────────────────────────────────────────────── + +model Recipe { + id String @id @default(cuid()) + userId String + title String + ingredients Json // [{ name: string, amount: string, available: boolean }] + steps Json // string[] + garnish String? + glassware String? + sourceDrinkId String? + notes String? @db.Text + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) + sourceDrink Drink? @relation(fields: [sourceDrinkId], references: [id], onDelete: SetNull) + + @@index([userId]) +} + +// ─── Flavor Profile ───────────────────────────────────────────── + +model FlavorProfile { + id String @id @default(cuid()) + userId String @unique + profileText String @db.Text + profileData Json? + generatedAt DateTime @default(now()) + ratingCount Int @default(0) + updatedAt DateTime @updatedAt + + user User @relation(fields: [userId], references: [id], onDelete: Cascade) +} diff --git a/src/app/(app)/bar/page.tsx b/src/app/(app)/bar/page.tsx new file mode 100644 index 0000000..41fa168 --- /dev/null +++ b/src/app/(app)/bar/page.tsx @@ -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 ( + }> + + + ) +} + +function BarLoading() { + return ( +
+
+
+ +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ +
+ {Array.from({ length: 4 }, (_, j) => ( + + ))} +
+
+ ))} +
+
+
+ ) +} + +const CATEGORY_ORDER = [ + "SPIRITS", + "LIQUEURS", + "MIXERS", + "BITTERS", + "GARNISHES", + "TOOLS", +] + +function BarContent() { + const [addDialogOpen, setAddDialogOpen] = useState(false) + const [editingItem, setEditingItem] = useState(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>( + (groups, item) => { + const key = item.category + if (!groups[key]) groups[key] = [] + groups[key].push(item) + return groups + }, + {} + ) + + const totalItems = data?.items.length || 0 + + return ( +
+
+
+
+
+

My Bar

+

+ {totalItems > 0 + ? `${totalItems} item${totalItems !== 1 ? "s" : ""} in your bar` + : "Your bar inventory"} +

+
+ +
+ + {isLoading ? ( +
+ {Array.from({ length: 3 }, (_, i) => ( +
+ +
+ {Array.from({ length: 4 }, (_, j) => ( + + ))} +
+
+ ))} +
+ ) : error ? ( +
+

+ Failed to load bar items. Please try again. +

+
+ ) : totalItems === 0 ? ( +
+ +
+

Your bar is empty

+

+ Add your first item to get started. +

+
+ +
+ ) : ( +
+ {CATEGORY_ORDER.filter((cat) => groupedItems[cat]?.length > 0).map( + (cat) => ( + + ) + )} +
+ )} +
+ + {/* Add Item Dialog */} + + + + Add Bar Item + + Add a spirit, mixer, or other item to your bar inventory. + + + + {createBarItem.isError && ( +

+ {createBarItem.error.message || "Failed to add item"} +

+ )} +
+
+ + {/* Edit Item Dialog */} + { + if (!open) setEditingItem(null) + }} + > + + + Edit Bar Item + + Update the details for this item. + + + {editingItem && ( + <> + + {updateBarItem.isError && ( +

+ {updateBarItem.error.message || "Failed to update item"} +

+ )} + + )} +
+
+
+ ) +} diff --git a/src/app/(app)/bartender/page.tsx b/src/app/(app)/bartender/page.tsx new file mode 100644 index 0000000..54d7a64 --- /dev/null +++ b/src/app/(app)/bartender/page.tsx @@ -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 ( + }> + + + ) +} + +function BartenderLoading() { + return ( +
+
+
+ + +
+ {Array.from({ length: 4 }, (_, i) => ( + + ))} +
+
+
+ ) +} + +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("recreate") + + return ( +
+
+
+
+

Bartender

+

+ AI-powered cocktail recipes based on your bar inventory. +

+
+ + {/* Tab switcher */} +
+ {TABS.map((tab) => { + const Icon = tab.icon + return ( + + ) + })} +
+ + {/* Tab content */} + {activeTab === "recreate" && } + {activeTab === "suggest" && } + {activeTab === "saved" && } +
+
+ ) +} diff --git a/src/app/(app)/drinks/[id]/page.tsx b/src/app/(app)/drinks/[id]/page.tsx index ec590b9..152211c 100644 --- a/src/app/(app)/drinks/[id]/page.tsx +++ b/src/app/(app)/drinks/[id]/page.tsx @@ -74,6 +74,15 @@ export default async function DrinkDetailPage({ Back to Collection + {/* Drink Image */} + {drink.imageUrl && ( + {drink.name} + )} + {/* Main Info Card */} diff --git a/src/app/(app)/recommend/page.tsx b/src/app/(app)/recommend/page.tsx new file mode 100644 index 0000000..3f0ed0b --- /dev/null +++ b/src/app/(app)/recommend/page.tsx @@ -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 ( + }> + + + ) +} + +function RecommendLoading() { + return ( +
+
+
+ + + + +
+
+ ) +} + +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 ( +
+
+
+
+

+ + Recommendations +

+

+ AI-powered drink suggestions tailored to your taste. +

+
+ + generateProfile.mutate()} + /> + + + + +
+
+ ) +} diff --git a/src/app/api/bar/[id]/route.ts b/src/app/api/bar/[id]/route.ts new file mode 100644 index 0000000..1109eed --- /dev/null +++ b/src/app/api/bar/[id]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/bar/route.ts b/src/app/api/bar/route.ts new file mode 100644 index 0000000..4e753f7 --- /dev/null +++ b/src/app/api/bar/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/bartender/recreate/route.ts b/src/app/api/bartender/recreate/route.ts new file mode 100644 index 0000000..b204936 --- /dev/null +++ b/src/app/api/bartender/recreate/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/bartender/suggest/route.ts b/src/app/api/bartender/suggest/route.ts new file mode 100644 index 0000000..fe24c7a --- /dev/null +++ b/src/app/api/bartender/suggest/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/recipes/[id]/route.ts b/src/app/api/recipes/[id]/route.ts new file mode 100644 index 0000000..afc6428 --- /dev/null +++ b/src/app/api/recipes/[id]/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/recipes/route.ts b/src/app/api/recipes/route.ts new file mode 100644 index 0000000..2a9ad11 --- /dev/null +++ b/src/app/api/recipes/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/recommend/profile/route.ts b/src/app/api/recommend/profile/route.ts new file mode 100644 index 0000000..d794595 --- /dev/null +++ b/src/app/api/recommend/profile/route.ts @@ -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 + 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 } + ) + } +} diff --git a/src/app/api/recommend/similar/route.ts b/src/app/api/recommend/similar/route.ts new file mode 100644 index 0000000..dacb1d2 --- /dev/null +++ b/src/app/api/recommend/similar/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/recommend/suggest/route.ts b/src/app/api/recommend/suggest/route.ts new file mode 100644 index 0000000..9061e9e --- /dev/null +++ b/src/app/api/recommend/suggest/route.ts @@ -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 } + ) + } +} diff --git a/src/app/api/settings/backup/route.ts b/src/app/api/settings/backup/route.ts index 3a33ddb..99b81ac 100644 --- a/src/app/api/settings/backup/route.ts +++ b/src/app/api/settings/backup/route.ts @@ -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] diff --git a/src/components/bar/bar-category-group.tsx b/src/components/bar/bar-category-group.tsx new file mode 100644 index 0000000..54506df --- /dev/null +++ b/src/components/bar/bar-category-group.tsx @@ -0,0 +1,70 @@ +"use client" + +import { + Wine, + Droplets, + GlassWater, + FlaskConical, + Flower2, + Wrench, +} from "lucide-react" +import { BarItemCard } from "@/components/bar/bar-item-card" +import type { BarItem } from "@/hooks/use-bar" + +const CATEGORY_ICONS: Record = { + SPIRITS: Wine, + LIQUEURS: Droplets, + MIXERS: GlassWater, + BITTERS: FlaskConical, + GARNISHES: Flower2, + TOOLS: Wrench, +} + +const CATEGORY_LABELS: Record = { + SPIRITS: "Spirits", + LIQUEURS: "Liqueurs", + MIXERS: "Mixers", + BITTERS: "Bitters", + GARNISHES: "Garnishes", + TOOLS: "Tools", +} + +interface BarCategoryGroupProps { + category: string + items: BarItem[] + onEdit: (item: BarItem) => void + onDelete: (item: BarItem) => void +} + +export function BarCategoryGroup({ + category, + items, + onEdit, + onDelete, +}: BarCategoryGroupProps) { + const Icon = CATEGORY_ICONS[category] || Wine + + return ( +
+
+ +

+ {CATEGORY_LABELS[category] || category} +

+ + ({items.length}) + +
+
+ {items.map((item) => ( + + ))} +
+
+ ) +} diff --git a/src/components/bar/bar-item-card.tsx b/src/components/bar/bar-item-card.tsx new file mode 100644 index 0000000..d5ddc78 --- /dev/null +++ b/src/components/bar/bar-item-card.tsx @@ -0,0 +1,105 @@ +"use client" + +import { Card, CardContent } from "@/components/ui/card" +import { Badge } from "@/components/ui/badge" +import { Button } from "@/components/ui/button" +import { Pencil, Trash2 } from "lucide-react" +import { cn } from "@/lib/utils" +import type { BarItem } from "@/hooks/use-bar" + +const CATEGORY_COLORS: Record = { + SPIRITS: "bg-amber-500/15 text-amber-700 border-amber-500/25", + LIQUEURS: "bg-purple-500/15 text-purple-700 border-purple-500/25", + MIXERS: "bg-sky-500/15 text-sky-700 border-sky-500/25", + BITTERS: "bg-orange-500/15 text-orange-700 border-orange-500/25", + GARNISHES: "bg-green-500/15 text-green-700 border-green-500/25", + TOOLS: "bg-slate-500/15 text-slate-700 border-slate-500/25", +} + +const CATEGORY_LABELS: Record = { + SPIRITS: "Spirits", + LIQUEURS: "Liqueurs", + MIXERS: "Mixers", + BITTERS: "Bitters", + GARNISHES: "Garnishes", + TOOLS: "Tools", +} + +const QUANTITY_COLORS: Record = { + FULL: "bg-green-500/15 text-green-700 border-green-500/25", + HALF: "bg-yellow-500/15 text-yellow-700 border-yellow-500/25", + LOW: "bg-orange-500/15 text-orange-700 border-orange-500/25", + EMPTY: "bg-red-500/15 text-red-700 border-red-500/25", +} + +const QUANTITY_LABELS: Record = { + FULL: "Full", + HALF: "Half", + LOW: "Low", + EMPTY: "Empty", +} + +interface BarItemCardProps { + item: BarItem + onEdit: (item: BarItem) => void + onDelete: (item: BarItem) => void +} + +export function BarItemCard({ item, onEdit, onDelete }: BarItemCardProps) { + return ( + + +
+

+ {item.name} +

+
+ + +
+
+ +
+ + {CATEGORY_LABELS[item.category] || item.category} + + + {QUANTITY_LABELS[item.quantity] || item.quantity} + +
+ + {item.notes && ( +

+ {item.notes} +

+ )} +
+
+ ) +} diff --git a/src/components/bar/bar-item-form.tsx b/src/components/bar/bar-item-form.tsx new file mode 100644 index 0000000..e74638d --- /dev/null +++ b/src/components/bar/bar-item-form.tsx @@ -0,0 +1,144 @@ +"use client" + +import { useState } from "react" +import { Button } from "@/components/ui/button" +import { Input } from "@/components/ui/input" +import { Label } from "@/components/ui/label" +import { Textarea } from "@/components/ui/textarea" +import { Select, SelectOption } from "@/components/ui/select" +import type { BarItemCreate } from "@/lib/validators" + +const CATEGORIES = [ + { value: "SPIRITS", label: "Spirits" }, + { value: "LIQUEURS", label: "Liqueurs" }, + { value: "MIXERS", label: "Mixers" }, + { value: "BITTERS", label: "Bitters" }, + { value: "GARNISHES", label: "Garnishes" }, + { value: "TOOLS", label: "Tools" }, +] + +const QUANTITIES = [ + { value: "FULL", label: "Full" }, + { value: "HALF", label: "Half" }, + { value: "LOW", label: "Low" }, + { value: "EMPTY", label: "Empty" }, +] + +interface BarItemFormProps { + initialData?: Partial + onSubmit: (data: BarItemCreate) => void + isSubmitting?: boolean + submitLabel?: string +} + +export function BarItemForm({ + initialData, + onSubmit, + isSubmitting = false, + submitLabel = "Save Item", +}: BarItemFormProps) { + const [name, setName] = useState(initialData?.name || "") + const [category, setCategory] = useState(initialData?.category || "SPIRITS") + const [quantity, setQuantity] = useState(initialData?.quantity || "FULL") + const [notes, setNotes] = useState(initialData?.notes || "") + const [errors, setErrors] = useState>({}) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + + const newErrors: Record = {} + if (!name.trim()) { + newErrors.name = "Name is required" + } + + if (Object.keys(newErrors).length > 0) { + setErrors(newErrors) + return + } + + setErrors({}) + + const data: BarItemCreate = { + name: name.trim(), + category: category as BarItemCreate["category"], + quantity: quantity as BarItemCreate["quantity"], + } + + if (notes.trim()) data.notes = notes.trim() + + onSubmit(data) + } + + return ( +
+
+ + setName(e.target.value)} + /> + {errors.name && ( +

{errors.name}

+ )} +
+ +
+
+ + +
+ +
+ + +
+
+ +
+
+ + + {notes.length}/2000 + +
+