Initial commit: DrinkTracker full-stack app

Next.js 14 drink collection tracker with AI-powered search,
menu scanning, ratings, wishlist, sharing, and CSV backup/restore.

Features:
- Auth (credentials + OAuth ready)
- Drink collection with ratings and reviews
- AI search via Claude/OpenAI with search history
- Menu photo scanning with AI extraction
- Wishlist / Try Later system
- Public sharing via slug URLs
- CSV backup and restore (merge/replace modes)
- Docker Compose for Postgres + MinIO + dev server

Security: docker-compose files use env var interpolation
instead of hardcoded secrets.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
JP Scott
2026-03-01 12:27:08 -07:00
commit 969bc9347a
115 changed files with 19397 additions and 0 deletions

209
src/lib/ai/base-provider.ts Normal file
View File

@@ -0,0 +1,209 @@
import type {
AIProvider,
ExtractedMenuItem,
MenuExtractionResult,
DrinkRecommendation,
RecommendationResult,
LabelExtractionResult,
DrinkSearchResult,
UserDrinkSummary,
UserPreferenceSummary,
} from "./types"
import {
MENU_EXTRACTION_PROMPT,
LABEL_EXTRACTION_PROMPT,
DRINK_SEARCH_PROMPT,
buildRecommendationPrompt,
} from "./prompts"
export abstract class BaseAIProvider implements AIProvider {
abstract name: string
abstract sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string>
abstract sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string>
async extractMenuItems(
imageBase64: string,
mimeType: string
): Promise<MenuExtractionResult> {
const rawResponse = await this.sendVisionRequest(
MENU_EXTRACTION_PROMPT,
imageBase64,
mimeType
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const items: ExtractedMenuItem[] = Array.isArray(parsed) ? parsed : []
const validatedItems = items.map((item) => ({
name: String(item.name || "Unknown"),
type: this.validateDrinkType(item.type),
...(item.subType && { subType: String(item.subType) }),
...(item.brewery && { brewery: String(item.brewery) }),
...(item.abv != null && { abv: Number(item.abv) }),
...(item.price && { price: String(item.price) }),
...(item.description && { description: String(item.description) }),
}))
return { items: validatedItems, rawResponse }
} catch (error) {
console.error("Failed to parse menu extraction response:", error)
return { items: [], rawResponse }
}
}
async recommendDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): Promise<RecommendationResult> {
const prompt = buildRecommendationPrompt(
extractedItems,
userDrinks,
preferences
)
const rawResponse = await this.sendTextRequest(
prompt,
"Please provide your drink recommendations based on the information above."
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const recommendations: DrinkRecommendation[] = Array.isArray(parsed)
? parsed
: []
const validatedRecs = recommendations.map((rec) => ({
itemName: String(rec.itemName || ""),
reason: String(rec.reason || ""),
confidence: Math.min(1, Math.max(0, Number(rec.confidence) || 0)),
}))
return { recommendations: validatedRecs, rawResponse }
} catch (error) {
console.error("Failed to parse recommendation response:", error)
return { recommendations: [], rawResponse }
}
}
async extractLabel(
imageBase64: string,
mimeType: string
): Promise<LabelExtractionResult> {
const rawResponse = await this.sendVisionRequest(
LABEL_EXTRACTION_PROMPT,
imageBase64,
mimeType
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
return {
name: String(parsed.name || "Unknown"),
type: this.validateDrinkType(parsed.type),
...(parsed.subType && { subType: String(parsed.subType) }),
...(parsed.brewery && { brewery: String(parsed.brewery) }),
...(parsed.region && { region: String(parsed.region) }),
...(parsed.abv != null && { abv: Number(parsed.abv) }),
...(parsed.description && { description: String(parsed.description) }),
rawResponse,
}
} catch (error) {
console.error("Failed to parse label extraction response:", error)
return {
name: "Unknown",
type: "OTHER",
rawResponse,
}
}
}
async searchDrinks(query: string): Promise<DrinkSearchResult> {
const rawResponse = await this.sendTextRequest(
DRINK_SEARCH_PROMPT,
`Search for: ${query}`
)
try {
const parsed = this.parseJsonFromResponse(rawResponse)
const drinks: ExtractedMenuItem[] = Array.isArray(parsed) ? parsed : []
const validatedDrinks = drinks.map((item) => ({
name: String(item.name || "Unknown"),
type: this.validateDrinkType(item.type),
...(item.subType && { subType: String(item.subType) }),
...(item.brewery && { brewery: String(item.brewery) }),
...(item.abv != null && { abv: Number(item.abv) }),
...(item.description && { description: String(item.description) }),
}))
return { drinks: validatedDrinks, rawResponse }
} catch (error) {
console.error("Failed to parse drink search response:", error)
return { drinks: [], rawResponse }
}
}
protected parseJsonFromResponse(text: string): any {
// Try direct parse first
try {
return JSON.parse(text)
} catch {
// Continue to other strategies
}
// Try to extract from markdown code blocks: ```json ... ``` or ``` ... ```
const codeBlockMatch = text.match(/```(?:json)?\s*\n?([\s\S]*?)\n?```/)
if (codeBlockMatch) {
try {
return JSON.parse(codeBlockMatch[1].trim())
} catch {
// Continue to other strategies
}
}
// Try to find JSON array in the text
const arrayMatch = text.match(/\[[\s\S]*\]/)
if (arrayMatch) {
try {
return JSON.parse(arrayMatch[0])
} catch {
// Continue to other strategies
}
}
// Try to find JSON object in the text
const objectMatch = text.match(/\{[\s\S]*\}/)
if (objectMatch) {
try {
return JSON.parse(objectMatch[0])
} catch {
// Continue to other strategies
}
}
throw new Error(`Could not extract valid JSON from response: ${text.slice(0, 200)}...`)
}
private validateDrinkType(
type: unknown
): "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER" {
const validTypes = ["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"] as const
const upper = String(type || "").toUpperCase()
if (validTypes.includes(upper as (typeof validTypes)[number])) {
return upper as (typeof validTypes)[number]
}
return "OTHER"
}
}

View File

@@ -0,0 +1,78 @@
import Anthropic from "@anthropic-ai/sdk"
import { BaseAIProvider } from "./base-provider"
export class ClaudeProvider extends BaseAIProvider {
name = "claude"
private client: Anthropic
constructor(apiKey: string) {
super()
this.client = new Anthropic({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: [
{
type: "image",
source: {
type: "base64",
media_type: mimeType as
| "image/jpeg"
| "image/png"
| "image/gif"
| "image/webp",
data: imageBase64,
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.messages.create({
model: "claude-sonnet-4-20250514",
max_tokens: 4096,
system: systemPrompt,
messages: [
{
role: "user",
content: userMessage,
},
],
})
const textBlock = response.content.find((block) => block.type === "text")
if (!textBlock || textBlock.type !== "text") {
throw new Error("No text response received from Claude")
}
return textBlock.text
}
}

291
src/lib/ai/menu-analyzer.ts Normal file
View File

@@ -0,0 +1,291 @@
import { prisma } from "@/lib/prisma"
import { decrypt } from "@/lib/encryption"
import { createProvider } from "./provider-factory"
import type {
ExtractedMenuItem,
MenuExtractionResult,
RecommendationResult,
LabelExtractionResult,
UserDrinkSummary,
UserPreferenceSummary,
} from "./types"
interface MatchedItem {
menuItem: ExtractedMenuItem
drinkId: string
drinkName: string
avgRating: number | null
wouldReorder: boolean
}
interface MenuAnalysisResult {
extractedItems: ExtractedMenuItem[]
matchedItems: MatchedItem[]
recommendations: RecommendationResult
rawResponse: string
provider: string
}
async function getProviderForUser(userId: string) {
const apiKeyRecord = await prisma.userApiKey.findFirst({
where: { userId, isActive: true },
orderBy: { updatedAt: "desc" },
})
if (!apiKeyRecord) {
throw new Error(
"No active API key found. Please add an AI provider API key in Settings."
)
}
const apiKey = decrypt(apiKeyRecord.encryptedKey, apiKeyRecord.iv)
const provider = createProvider(apiKeyRecord.provider, apiKey)
return { provider, providerName: apiKeyRecord.provider }
}
async function getUserDrinkSummaries(
userId: string
): Promise<UserDrinkSummary[]> {
const drinks = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
select: {
score: true,
wouldReorder: true,
},
},
},
})
return drinks.map((drink) => {
const ratings = drink.ratings
const avgRating =
ratings.length > 0
? ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length
: null
const wouldReorder = ratings.some((r) => r.wouldReorder)
return {
name: drink.name,
type: drink.type,
subType: drink.subType,
brewery: drink.brewery,
avgRating: avgRating !== null ? Math.round(avgRating * 10) / 10 : null,
wouldReorder,
}
})
}
async function getUserPreferences(
userId: string
): Promise<UserPreferenceSummary | null> {
const prefs = await prisma.userPreference.findUnique({
where: { userId },
})
if (!prefs) return null
return {
preferredStyles: prefs.preferredStyles,
avoidedStyles: prefs.avoidedStyles,
minAbv: prefs.minAbv,
maxAbv: prefs.maxAbv,
}
}
function normalizeForComparison(text: string): string {
return text
.toLowerCase()
.replace(/[^a-z0-9]/g, "")
.trim()
}
function fuzzyMatch(a: string, b: string): boolean {
const normA = normalizeForComparison(a)
const normB = normalizeForComparison(b)
// Exact match after normalization
if (normA === normB) return true
// One contains the other
if (normA.includes(normB) || normB.includes(normA)) return true
// Levenshtein distance for short strings — allow minor typos
if (normA.length > 3 && normB.length > 3) {
const distance = levenshteinDistance(normA, normB)
const maxLen = Math.max(normA.length, normB.length)
const similarity = 1 - distance / maxLen
if (similarity >= 0.8) return true
}
return false
}
function levenshteinDistance(a: string, b: string): number {
const matrix: number[][] = []
for (let i = 0; i <= b.length; i++) {
matrix[i] = [i]
}
for (let j = 0; j <= a.length; j++) {
matrix[0][j] = j
}
for (let i = 1; i <= b.length; i++) {
for (let j = 1; j <= a.length; j++) {
if (b[i - 1] === a[j - 1]) {
matrix[i][j] = matrix[i - 1][j - 1]
} else {
matrix[i][j] = Math.min(
matrix[i - 1][j - 1] + 1, // substitution
matrix[i][j - 1] + 1, // insertion
matrix[i - 1][j] + 1 // deletion
)
}
}
}
return matrix[b.length][a.length]
}
function matchExtractedToUserDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: Array<{
id: string
name: string
type: string
subType: string | null
brewery: string | null
ratings: Array<{ score: number; wouldReorder: boolean }>
}>
): { matched: MatchedItem[]; unmatched: ExtractedMenuItem[] } {
const matched: MatchedItem[] = []
const unmatched: ExtractedMenuItem[] = []
for (const menuItem of extractedItems) {
let bestMatch: (typeof userDrinks)[number] | null = null
for (const drink of userDrinks) {
// Primary match: name
if (fuzzyMatch(menuItem.name, drink.name)) {
bestMatch = drink
break
}
// Secondary match: name + brewery combo
if (
menuItem.brewery &&
drink.brewery &&
fuzzyMatch(menuItem.brewery, drink.brewery) &&
fuzzyMatch(menuItem.name, drink.name)
) {
bestMatch = drink
break
}
}
if (bestMatch) {
const ratings = bestMatch.ratings
const avgRating =
ratings.length > 0
? Math.round(
(ratings.reduce((sum, r) => sum + r.score, 0) / ratings.length) *
10
) / 10
: null
const wouldReorder = ratings.some((r) => r.wouldReorder)
matched.push({
menuItem,
drinkId: bestMatch.id,
drinkName: bestMatch.name,
avgRating,
wouldReorder,
})
} else {
unmatched.push(menuItem)
}
}
return { matched, unmatched }
}
export async function analyzeMenu(
imageBase64: string,
mimeType: string,
userId: string
): Promise<MenuAnalysisResult> {
// Step 1: Get AI provider for user
const { provider, providerName } = await getProviderForUser(userId)
// Step 2: Extract menu items from image
const extraction: MenuExtractionResult = await provider.extractMenuItems(
imageBase64,
mimeType
)
if (extraction.items.length === 0) {
return {
extractedItems: [],
matchedItems: [],
recommendations: { recommendations: [], rawResponse: extraction.rawResponse },
rawResponse: extraction.rawResponse,
provider: providerName,
}
}
// Step 3: Get user's drinks with ratings from database
const userDrinksWithRatings = await prisma.drink.findMany({
where: { userId },
include: {
ratings: {
select: {
score: true,
wouldReorder: true,
},
},
},
})
// Step 4: Match extracted items against user's collection
const { matched, unmatched } = matchExtractedToUserDrinks(
extraction.items,
userDrinksWithRatings
)
// Step 5: Get recommendations for unmatched items
let recommendations: RecommendationResult = {
recommendations: [],
rawResponse: "",
}
if (unmatched.length > 0) {
const userDrinkSummaries = await getUserDrinkSummaries(userId)
const preferences = await getUserPreferences(userId)
recommendations = await provider.recommendDrinks(
unmatched,
userDrinkSummaries,
preferences
)
}
return {
extractedItems: extraction.items,
matchedItems: matched,
recommendations,
rawResponse: extraction.rawResponse,
provider: providerName,
}
}
export async function analyzeLabel(
imageBase64: string,
mimeType: string,
userId: string
): Promise<LabelExtractionResult> {
const { provider } = await getProviderForUser(userId)
return provider.extractLabel(imageBase64, mimeType)
}

View File

@@ -0,0 +1,81 @@
import OpenAI from "openai"
import { BaseAIProvider } from "./base-provider"
export class OpenAIProvider extends BaseAIProvider {
name = "openai"
private client: OpenAI
constructor(apiKey: string) {
super()
this.client = new OpenAI({ apiKey })
}
async sendVisionRequest(
systemPrompt: string,
imageBase64: string,
mimeType: string
): Promise<string> {
const dataUrl = `data:${mimeType};base64,${imageBase64}`
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: [
{
type: "image_url",
image_url: {
url: dataUrl,
detail: "high",
},
},
{
type: "text",
text: "Please analyze this image and extract the information as instructed.",
},
],
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
async sendTextRequest(
systemPrompt: string,
userMessage: string
): Promise<string> {
const response = await this.client.chat.completions.create({
model: "gpt-4o",
max_tokens: 4096,
messages: [
{
role: "system",
content: systemPrompt,
},
{
role: "user",
content: userMessage,
},
],
})
const message = response.choices[0]?.message?.content
if (!message) {
throw new Error("No response received from OpenAI")
}
return message
}
}

168
src/lib/ai/prompts.ts Normal file
View File

@@ -0,0 +1,168 @@
import type { ExtractedMenuItem, UserDrinkSummary, UserPreferenceSummary } from "./types"
export const MENU_EXTRACTION_PROMPT = `You are an expert at reading drink menus from photos. Analyze the provided menu image and extract every drink item you can identify.
For each item, return the following fields:
- "name" (string, required): The name of the drink as it appears on the menu.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Pinot Noir", "Margarita", "Bourbon").
- "brewery" (string, optional): The brewery, winery, or distillery name if listed.
- "abv" (number, optional): The alcohol by volume as a decimal number (e.g., 5.5 for 5.5%). Only include if explicitly shown on the menu.
- "price" (string, optional): The price as shown on the menu (e.g., "$8", "$12/glass"). Include the currency symbol.
- "description" (string, optional): Any tasting notes or description provided on the menu.
Return your response as a valid JSON array of objects. Do not include any text before or after the JSON array. Example format:
[
{
"name": "Hazy Little Thing",
"type": "BEER",
"subType": "Hazy IPA",
"brewery": "Sierra Nevada",
"abv": 6.7,
"price": "$7",
"description": "Unfiltered, unprocessed IPA with tropical hop character"
}
]
If no drink items can be identified in the image, return an empty array: []
Important:
- Extract ALL visible items, even if some fields are unclear.
- If a field is not visible or cannot be determined, omit it rather than guessing.
- Classify the type based on context clues if not explicitly stated.
- For sections labeled "Draft", "On Tap", "Bottles", "Cans" — these are typically BEER.
- For sections labeled "Red", "White", "Rosé", "Sparkling" — these are typically WINE.
- For sections labeled "Cocktails", "Signature Drinks", "Mixed Drinks" — these are typically COCKTAIL.`
export const LABEL_EXTRACTION_PROMPT = `You are an expert at reading drink labels from photos. Analyze the provided label image and extract all information about the drink.
Return your response as a single valid JSON object with the following fields:
- "name" (string, required): The name of the drink.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Cabernet Sauvignon", "Bourbon").
- "brewery" (string, optional): The brewery, winery, or distillery name.
- "region" (string, optional): The geographic region or origin (e.g., "Napa Valley", "Portland, OR", "Scotland").
- "abv" (number, optional): The alcohol by volume as a decimal number (e.g., 5.5 for 5.5%).
- "description" (string, optional): Any tasting notes, taglines, or descriptive text from the label.
Do not include any text before or after the JSON object. Example format:
{
"name": "Two Hearted Ale",
"type": "BEER",
"subType": "American IPA",
"brewery": "Bell's Brewery",
"region": "Comstock, MI",
"abv": 7.0,
"description": "Brewed with 100% Centennial hops for a bold, balanced American IPA"
}
Important:
- Read the label carefully and extract only what is actually present.
- If a field is not visible or cannot be determined, omit it rather than guessing.
- For ABV, look for the "% alc/vol" or "ABV" label. Return only the number.`
export const DRINK_SEARCH_PROMPT = `You are a knowledgeable drink expert. The user is searching for a drink by name or description. Return detailed information about matching drinks.
Return your response as a valid JSON array of drink objects. Each object should have:
- "name" (string, required): The full, correct name of the drink.
- "type" (string, required): One of "BEER", "WINE", "COCKTAIL", "SPIRIT", or "OTHER".
- "subType" (string, optional): The style or sub-category (e.g., "IPA", "Stout", "Cabernet Sauvignon").
- "brewery" (string, optional): The brewery, winery, or distillery that makes it.
- "region" (string, optional): Where it's from.
- "abv" (number, optional): Typical ABV as a number.
- "description" (string, optional): A brief tasting note or description (1-2 sentences).
Important:
- Return up to 8 results, sorted by relevance to the query.
- Include the most likely exact match first, followed by similar or related drinks.
- If the query is vague (e.g., "a good IPA"), return popular well-known options.
- Only include information you are confident about. Omit fields rather than guessing.
- Do not include any text before or after the JSON array.`
export function buildRecommendationPrompt(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): string {
const itemsList = extractedItems
.map((item, i) => {
const parts = [`${i + 1}. ${item.name} (${item.type})`]
if (item.subType) parts.push(`Style: ${item.subType}`)
if (item.brewery) parts.push(`From: ${item.brewery}`)
if (item.abv) parts.push(`ABV: ${item.abv}%`)
if (item.description) parts.push(`Description: ${item.description}`)
return parts.join(" | ")
})
.join("\n")
const drinkHistory = userDrinks.length > 0
? userDrinks
.map((d) => {
const parts = [`- ${d.name} (${d.type})`]
if (d.subType) parts.push(`Style: ${d.subType}`)
if (d.brewery) parts.push(`From: ${d.brewery}`)
if (d.avgRating !== null) parts.push(`Avg Rating: ${d.avgRating}/5`)
parts.push(`Would Reorder: ${d.wouldReorder ? "Yes" : "No"}`)
return parts.join(" | ")
})
.join("\n")
: "No drink history available."
let preferencesText = "No specific preferences set."
if (preferences) {
const parts: string[] = []
if (preferences.preferredStyles.length > 0) {
parts.push(`Preferred styles: ${preferences.preferredStyles.join(", ")}`)
}
if (preferences.avoidedStyles.length > 0) {
parts.push(`Avoided styles: ${preferences.avoidedStyles.join(", ")}`)
}
if (preferences.minAbv != null) {
parts.push(`Minimum ABV: ${preferences.minAbv}%`)
}
if (preferences.maxAbv != null) {
parts.push(`Maximum ABV: ${preferences.maxAbv}%`)
}
if (parts.length > 0) {
preferencesText = parts.join("\n")
}
}
return `You are a knowledgeable drink recommendation assistant. Based on the user's drink history, preferences, and the available menu items, recommend drinks they would likely enjoy.
## User's Drink History
${drinkHistory}
## User's Preferences
${preferencesText}
## Available Menu Items
${itemsList}
## Instructions
Analyze the user's taste profile from their drink history and preferences. Then recommend items from the available menu that they would most likely enjoy. Consider:
- Drinks similar to ones they rated highly or would reorder
- Styles they prefer
- Avoid styles they dislike
- Respect their ABV range preferences if set
- If they have no history, recommend popular crowd-pleasers
Return your response as a valid JSON array of recommendation objects. Each object should have:
- "itemName" (string): The exact name of the menu item you are recommending.
- "reason" (string): A brief, personalized explanation of why you think they would enjoy this drink (1-2 sentences).
- "confidence" (number): A confidence score between 0 and 1 indicating how well this matches their taste profile.
Sort recommendations by confidence (highest first). Return up to 5 recommendations.
Do not include any text before or after the JSON array. Example format:
[
{
"itemName": "Hazy Little Thing",
"reason": "You've rated several IPAs highly, and this hazy IPA has similar tropical hop notes to beers you've enjoyed.",
"confidence": 0.92
}
]`
}

View File

@@ -0,0 +1,14 @@
import type { AIProvider } from "./types"
import { ClaudeProvider } from "./claude-provider"
import { OpenAIProvider } from "./openai-provider"
export function createProvider(providerName: string, apiKey: string): AIProvider {
switch (providerName) {
case "claude":
return new ClaudeProvider(apiKey)
case "openai":
return new OpenAIProvider(apiKey)
default:
throw new Error(`Unknown AI provider: "${providerName}". Supported providers: "claude", "openai".`)
}
}

69
src/lib/ai/types.ts Normal file
View File

@@ -0,0 +1,69 @@
export interface ExtractedMenuItem {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
abv?: number
price?: string
description?: string
}
export interface MenuExtractionResult {
items: ExtractedMenuItem[]
rawResponse: string
}
export interface DrinkRecommendation {
itemName: string
reason: string
confidence: number // 0-1
}
export interface RecommendationResult {
recommendations: DrinkRecommendation[]
rawResponse: string
}
export interface LabelExtractionResult {
name: string
type: "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
subType?: string
brewery?: string
region?: string
abv?: number
description?: string
rawResponse: string
}
export interface DrinkSearchResult {
drinks: ExtractedMenuItem[]
rawResponse: string
}
export interface AIProvider {
name: string
extractMenuItems(imageBase64: string, mimeType: string): Promise<MenuExtractionResult>
recommendDrinks(
extractedItems: ExtractedMenuItem[],
userDrinks: UserDrinkSummary[],
preferences: UserPreferenceSummary | null
): Promise<RecommendationResult>
extractLabel(imageBase64: string, mimeType: string): Promise<LabelExtractionResult>
searchDrinks(query: string): Promise<DrinkSearchResult>
}
export interface UserDrinkSummary {
name: string
type: string
subType?: string | null
brewery?: string | null
avgRating: number | null
wouldReorder: boolean
}
export interface UserPreferenceSummary {
preferredStyles: string[]
avoidedStyles: string[]
minAbv?: number | null
maxAbv?: number | null
}

83
src/lib/auth.ts Normal file
View File

@@ -0,0 +1,83 @@
import NextAuth from "next-auth"
import Google from "next-auth/providers/google"
import GitHub from "next-auth/providers/github"
import Credentials from "next-auth/providers/credentials"
import { PrismaAdapter } from "@auth/prisma-adapter"
import { prisma } from "@/lib/prisma"
const providers = [
Google({
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
}),
GitHub({
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
}),
Credentials({
name: "Email",
credentials: {
email: { label: "Email", type: "email" },
password: { label: "Password", type: "password" },
},
async authorize(credentials) {
const email = credentials?.email as string
const password = credentials?.password as string
if (!email || !password) return null
const user = await prisma.user.findUnique({ where: { email } })
if (!user || !user.password) return null
const bcrypt = await import("bcryptjs")
const valid = await bcrypt.compare(password, user.password)
if (!valid) return null
return { id: user.id, email: user.email, name: user.name, image: user.image }
},
}),
]
export const { handlers, signIn, signOut, auth } = NextAuth({
adapter: PrismaAdapter(prisma),
providers,
session: {
strategy: "jwt", // needed for credentials provider
},
pages: {
signIn: "/login",
},
callbacks: {
jwt({ token, user }) {
if (user) {
token.id = user.id
}
return token
},
session({ session, token }) {
if (session.user && token.id) {
session.user.id = token.id as string
}
return session
},
authorized({ auth, request: { nextUrl } }) {
const isLoggedIn = !!auth?.user
const isOnApp = nextUrl.pathname.startsWith("/dashboard") ||
nextUrl.pathname.startsWith("/scan") ||
nextUrl.pathname.startsWith("/drinks") ||
nextUrl.pathname.startsWith("/rate") ||
nextUrl.pathname.startsWith("/settings") ||
nextUrl.pathname.startsWith("/wishlist")
if (isOnApp) {
if (isLoggedIn) return true
return false
}
if (isLoggedIn && (nextUrl.pathname === "/login" || nextUrl.pathname === "/register")) {
return Response.redirect(new URL("/dashboard", nextUrl))
}
return true
},
},
})

713
src/lib/backup.ts Normal file
View File

@@ -0,0 +1,713 @@
import { objectsToCsv } from "@/lib/csv"
import { prisma } from "@/lib/prisma"
import type {
Drink,
Rating,
WishlistItem,
UserPreference,
SharedList,
} from "@prisma/client"
import crypto from "crypto"
// ─── CSV Column Schema ──────────────────────────────────────────
const CSV_HEADERS = [
"_type",
"_originalId",
"_parentId",
"_drinkName",
"name",
"type",
"subType",
"brewery",
"region",
"abv",
"description",
"imageUrl",
"score",
"notes",
"wouldReorder",
"location",
"source",
"slug",
"title",
"listType",
"isPublic",
"drinkIds",
"preferredStyles",
"avoidedStyles",
"minAbv",
"maxAbv",
"defaultProvider",
"createdAt",
"updatedAt",
]
const VALID_DRINK_TYPES = ["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]
// ─── Parsed Types ───────────────────────────────────────────────
export interface ParsedDrink {
_originalId: string
name: string
type: string
subType?: string
brewery?: string
region?: string
abv?: number
description?: string
imageUrl?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedRating {
_originalId: string
_parentId: string
_drinkName: string
score: number
notes?: string
wouldReorder: boolean
location?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedWishlistItem {
_originalId: string
name: string
type: string
subType?: string
brewery?: string
abv?: number
description?: string
notes?: string
source?: string
createdAt?: Date
updatedAt?: Date
}
export interface ParsedPreferences {
preferredStyles: string[]
avoidedStyles: string[]
minAbv?: number
maxAbv?: number
defaultProvider?: string
}
export interface ParsedSharedList {
_originalId: string
title: string
description?: string
listType: string
isPublic: boolean
drinkIds: string[]
createdAt?: Date
updatedAt?: Date
}
export interface ParsedBackupData {
drinks: ParsedDrink[]
ratings: ParsedRating[]
wishlistItems: ParsedWishlistItem[]
preferences: ParsedPreferences | null
sharedLists: ParsedSharedList[]
}
export interface RestoreSummary {
drinks: { created: number; updated: number; skipped: number }
ratings: { created: number; updated: number; skipped: number }
wishlist: { created: number; updated: number; skipped: number }
preferences: { restored: boolean }
sharedLists: { created: number; updated: number; skipped: number }
}
// ─── Export ─────────────────────────────────────────────────────
export function generateBackupCsv(
drinks: Drink[],
ratings: (Rating & { drink: { name: string } })[],
wishlistItems: WishlistItem[],
preferences: UserPreference | null,
sharedLists: SharedList[]
): string {
const rows: Record<string, string>[] = []
// Drinks
for (const d of drinks) {
rows.push({
_type: "drink",
_originalId: d.id,
name: d.name,
type: d.type,
subType: d.subType ?? "",
brewery: d.brewery ?? "",
region: d.region ?? "",
abv: d.abv != null ? String(d.abv) : "",
description: d.description ?? "",
imageUrl: d.imageUrl ?? "",
createdAt: d.createdAt.toISOString(),
updatedAt: d.updatedAt.toISOString(),
})
}
// Ratings
for (const r of ratings) {
rows.push({
_type: "rating",
_originalId: r.id,
_parentId: r.drinkId,
_drinkName: r.drink.name,
score: String(r.score),
notes: r.notes ?? "",
wouldReorder: String(r.wouldReorder),
location: r.location ?? "",
createdAt: r.createdAt.toISOString(),
updatedAt: r.updatedAt.toISOString(),
})
}
// Wishlist items
for (const w of wishlistItems) {
rows.push({
_type: "wishlist",
_originalId: w.id,
name: w.name,
type: w.type,
subType: w.subType ?? "",
brewery: w.brewery ?? "",
abv: w.abv != null ? String(w.abv) : "",
description: w.description ?? "",
notes: w.notes ?? "",
source: w.source ?? "",
createdAt: w.createdAt.toISOString(),
updatedAt: w.updatedAt.toISOString(),
})
}
// Preferences
if (preferences) {
rows.push({
_type: "preference",
preferredStyles: preferences.preferredStyles.join("|"),
avoidedStyles: preferences.avoidedStyles.join("|"),
minAbv: preferences.minAbv != null ? String(preferences.minAbv) : "",
maxAbv: preferences.maxAbv != null ? String(preferences.maxAbv) : "",
defaultProvider: preferences.defaultProvider ?? "",
})
}
// Shared lists
for (const s of sharedLists) {
rows.push({
_type: "shared_list",
_originalId: s.id,
title: s.title,
description: s.description ?? "",
listType: s.listType,
isPublic: String(s.isPublic),
drinkIds: s.drinkIds.join("|"),
createdAt: s.createdAt.toISOString(),
updatedAt: s.updatedAt.toISOString(),
})
}
return objectsToCsv(CSV_HEADERS, rows)
}
// ─── Import: Parse ──────────────────────────────────────────────
function parseBoolean(val: string): boolean {
const lower = val.toLowerCase().trim()
return lower === "true" || lower === "1" || lower === "yes"
}
function parseOptionalFloat(val: string): number | undefined {
if (!val || val.trim() === "") return undefined
const n = parseFloat(val)
return isNaN(n) ? undefined : n
}
function parseOptionalDate(val: string): Date | undefined {
if (!val || val.trim() === "") return undefined
const d = new Date(val)
return isNaN(d.getTime()) ? undefined : d
}
export function parseBackupRows(
rows: Record<string, string>[]
): ParsedBackupData {
const data: ParsedBackupData = {
drinks: [],
ratings: [],
wishlistItems: [],
preferences: null,
sharedLists: [],
}
for (const row of rows) {
const rowType = row._type?.trim().toLowerCase()
switch (rowType) {
case "drink":
data.drinks.push({
_originalId: row._originalId ?? "",
name: row.name ?? "",
type: row.type ?? "OTHER",
subType: row.subType || undefined,
brewery: row.brewery || undefined,
region: row.region || undefined,
abv: parseOptionalFloat(row.abv ?? ""),
description: row.description || undefined,
imageUrl: row.imageUrl || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
case "rating":
data.ratings.push({
_originalId: row._originalId ?? "",
_parentId: row._parentId ?? "",
_drinkName: row._drinkName ?? "",
score: parseInt(row.score ?? "0", 10),
notes: row.notes || undefined,
wouldReorder: parseBoolean(row.wouldReorder ?? "false"),
location: row.location || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
case "wishlist":
data.wishlistItems.push({
_originalId: row._originalId ?? "",
name: row.name ?? "",
type: row.type ?? "OTHER",
subType: row.subType || undefined,
brewery: row.brewery || undefined,
abv: parseOptionalFloat(row.abv ?? ""),
description: row.description || undefined,
notes: row.notes || undefined,
source: row.source || undefined,
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
case "preference":
data.preferences = {
preferredStyles: row.preferredStyles
? row.preferredStyles.split("|").filter(Boolean)
: [],
avoidedStyles: row.avoidedStyles
? row.avoidedStyles.split("|").filter(Boolean)
: [],
minAbv: parseOptionalFloat(row.minAbv ?? ""),
maxAbv: parseOptionalFloat(row.maxAbv ?? ""),
defaultProvider: row.defaultProvider || undefined,
}
break
case "shared_list":
data.sharedLists.push({
_originalId: row._originalId ?? "",
title: row.title ?? "",
description: row.description || undefined,
listType: row.listType ?? "collection",
isPublic: parseBoolean(row.isPublic ?? "true"),
drinkIds: row.drinkIds
? row.drinkIds.split("|").filter(Boolean)
: [],
createdAt: parseOptionalDate(row.createdAt ?? ""),
updatedAt: parseOptionalDate(row.updatedAt ?? ""),
})
break
// Skip unknown row types
}
}
return data
}
// ─── Import: Validate ───────────────────────────────────────────
export function validateBackupData(
data: ParsedBackupData
): { valid: boolean; errors: string[] } {
const errors: string[] = []
for (let i = 0; i < data.drinks.length; i++) {
const d = data.drinks[i]
if (!d.name) errors.push(`Drink row ${i + 1}: name is required`)
if (!VALID_DRINK_TYPES.includes(d.type))
errors.push(
`Drink row ${i + 1}: invalid type "${d.type}". Expected one of: ${VALID_DRINK_TYPES.join(", ")}`
)
if (d.abv != null && (d.abv < 0 || d.abv > 100))
errors.push(`Drink row ${i + 1}: ABV must be 0-100`)
}
for (let i = 0; i < data.ratings.length; i++) {
const r = data.ratings[i]
if (!r.score || r.score < 1 || r.score > 5)
errors.push(`Rating row ${i + 1}: score must be 1-5`)
if (!r._parentId && !r._drinkName)
errors.push(
`Rating row ${i + 1}: must have _parentId or _drinkName to link to a drink`
)
}
for (let i = 0; i < data.wishlistItems.length; i++) {
const w = data.wishlistItems[i]
if (!w.name) errors.push(`Wishlist row ${i + 1}: name is required`)
if (!VALID_DRINK_TYPES.includes(w.type))
errors.push(
`Wishlist row ${i + 1}: invalid type "${w.type}". Expected one of: ${VALID_DRINK_TYPES.join(", ")}`
)
}
for (let i = 0; i < data.sharedLists.length; i++) {
const s = data.sharedLists[i]
if (!s.title) errors.push(`Shared list row ${i + 1}: title is required`)
}
return { valid: errors.length === 0, errors }
}
// ─── Import: Execute Restore ────────────────────────────────────
type RestoreMode = "merge-skip" | "merge-update" | "replace"
type DrinkType = "BEER" | "WINE" | "COCKTAIL" | "SPIRIT" | "OTHER"
export async function executeRestore(
userId: string,
data: ParsedBackupData,
mode: RestoreMode
): Promise<RestoreSummary> {
return await prisma.$transaction(
async (tx) => {
const summary: RestoreSummary = {
drinks: { created: 0, updated: 0, skipped: 0 },
ratings: { created: 0, updated: 0, skipped: 0 },
wishlist: { created: 0, updated: 0, skipped: 0 },
preferences: { restored: false },
sharedLists: { created: 0, updated: 0, skipped: 0 },
}
// STEP 0: If "replace", delete everything first
if (mode === "replace") {
await tx.rating.deleteMany({ where: { userId } })
await tx.sharedList.deleteMany({ where: { userId } })
await tx.drink.deleteMany({ where: { userId } })
await tx.wishlistItem.deleteMany({ where: { userId } })
await tx.userPreference.deleteMany({ where: { userId } })
}
// STEP 1: Preferences
if (data.preferences) {
if (mode === "replace") {
await tx.userPreference.create({
data: {
userId,
preferredStyles: data.preferences.preferredStyles,
avoidedStyles: data.preferences.avoidedStyles,
minAbv: data.preferences.minAbv ?? null,
maxAbv: data.preferences.maxAbv ?? null,
defaultProvider: data.preferences.defaultProvider ?? null,
},
})
summary.preferences.restored = true
} else {
const existing = await tx.userPreference.findUnique({
where: { userId },
})
if (!existing) {
await tx.userPreference.create({
data: {
userId,
preferredStyles: data.preferences.preferredStyles,
avoidedStyles: data.preferences.avoidedStyles,
minAbv: data.preferences.minAbv ?? null,
maxAbv: data.preferences.maxAbv ?? null,
defaultProvider: data.preferences.defaultProvider ?? null,
},
})
summary.preferences.restored = true
} else if (mode === "merge-update") {
await tx.userPreference.update({
where: { userId },
data: {
preferredStyles: data.preferences.preferredStyles,
avoidedStyles: data.preferences.avoidedStyles,
minAbv: data.preferences.minAbv ?? null,
maxAbv: data.preferences.maxAbv ?? null,
defaultProvider: data.preferences.defaultProvider ?? null,
},
})
summary.preferences.restored = true
}
}
}
// STEP 2: Drinks (build drinkIdMap)
const drinkIdMap = new Map<string, string>()
for (const drink of data.drinks) {
if (mode === "replace") {
const created = await tx.drink.create({
data: {
userId,
name: drink.name,
type: drink.type as DrinkType,
subType: drink.subType ?? null,
brewery: drink.brewery ?? null,
region: drink.region ?? null,
abv: drink.abv ?? null,
description: drink.description ?? null,
imageUrl: drink.imageUrl ?? null,
},
})
drinkIdMap.set(drink._originalId, created.id)
summary.drinks.created++
} else {
const existing = await tx.drink.findFirst({
where: {
userId,
name: drink.name,
type: drink.type as DrinkType,
brewery: drink.brewery ?? null,
},
})
if (existing) {
drinkIdMap.set(drink._originalId, existing.id)
if (mode === "merge-update") {
await tx.drink.update({
where: { id: existing.id },
data: {
subType: drink.subType ?? null,
region: drink.region ?? null,
abv: drink.abv ?? null,
description: drink.description ?? null,
imageUrl: drink.imageUrl ?? null,
},
})
summary.drinks.updated++
} else {
summary.drinks.skipped++
}
} else {
const created = await tx.drink.create({
data: {
userId,
name: drink.name,
type: drink.type as DrinkType,
subType: drink.subType ?? null,
brewery: drink.brewery ?? null,
region: drink.region ?? null,
abv: drink.abv ?? null,
description: drink.description ?? null,
imageUrl: drink.imageUrl ?? null,
},
})
drinkIdMap.set(drink._originalId, created.id)
summary.drinks.created++
}
}
}
// STEP 3: Ratings (use drinkIdMap)
for (const rating of data.ratings) {
let drinkId = drinkIdMap.get(rating._parentId)
// Fallback: match by drink name
if (!drinkId && rating._drinkName) {
const drinkByName = await tx.drink.findFirst({
where: { userId, name: rating._drinkName },
})
if (drinkByName) drinkId = drinkByName.id
}
if (!drinkId) {
summary.ratings.skipped++
continue
}
if (mode === "replace") {
await tx.rating.create({
data: {
userId,
drinkId,
score: rating.score,
notes: rating.notes ?? null,
wouldReorder: rating.wouldReorder,
location: rating.location ?? null,
},
})
summary.ratings.created++
} else {
const existing = await tx.rating.findFirst({
where: {
userId,
drinkId,
score: rating.score,
...(rating.createdAt ? { createdAt: rating.createdAt } : {}),
},
})
if (existing) {
if (mode === "merge-update") {
await tx.rating.update({
where: { id: existing.id },
data: {
notes: rating.notes ?? null,
wouldReorder: rating.wouldReorder,
location: rating.location ?? null,
},
})
summary.ratings.updated++
} else {
summary.ratings.skipped++
}
} else {
await tx.rating.create({
data: {
userId,
drinkId,
score: rating.score,
notes: rating.notes ?? null,
wouldReorder: rating.wouldReorder,
location: rating.location ?? null,
},
})
summary.ratings.created++
}
}
}
// STEP 4: Wishlist items
for (const item of data.wishlistItems) {
if (mode === "replace") {
await tx.wishlistItem.create({
data: {
userId,
name: item.name,
type: item.type as DrinkType,
subType: item.subType ?? null,
brewery: item.brewery ?? null,
abv: item.abv ?? null,
description: item.description ?? null,
notes: item.notes ?? null,
source: item.source ?? null,
},
})
summary.wishlist.created++
} else {
const existing = await tx.wishlistItem.findFirst({
where: {
userId,
name: item.name,
type: item.type as DrinkType,
},
})
if (existing) {
if (mode === "merge-update") {
await tx.wishlistItem.update({
where: { id: existing.id },
data: {
subType: item.subType ?? null,
brewery: item.brewery ?? null,
abv: item.abv ?? null,
description: item.description ?? null,
notes: item.notes ?? null,
source: item.source ?? null,
},
})
summary.wishlist.updated++
} else {
summary.wishlist.skipped++
}
} else {
await tx.wishlistItem.create({
data: {
userId,
name: item.name,
type: item.type as DrinkType,
subType: item.subType ?? null,
brewery: item.brewery ?? null,
abv: item.abv ?? null,
description: item.description ?? null,
notes: item.notes ?? null,
source: item.source ?? null,
},
})
summary.wishlist.created++
}
}
}
// STEP 5: Shared lists (map drinkIds)
for (const list of data.sharedLists) {
const mappedDrinkIds = list.drinkIds
.map((oldId) => drinkIdMap.get(oldId))
.filter(Boolean) as string[]
const slug = crypto.randomBytes(6).toString("hex")
if (mode === "replace") {
await tx.sharedList.create({
data: {
userId,
slug,
title: list.title,
description: list.description ?? null,
listType: list.listType,
isPublic: list.isPublic,
drinkIds: mappedDrinkIds,
},
})
summary.sharedLists.created++
} else {
const existing = await tx.sharedList.findFirst({
where: {
userId,
title: list.title,
listType: list.listType,
},
})
if (existing) {
if (mode === "merge-update") {
await tx.sharedList.update({
where: { id: existing.id },
data: {
description: list.description ?? null,
isPublic: list.isPublic,
drinkIds: mappedDrinkIds,
},
})
summary.sharedLists.updated++
} else {
summary.sharedLists.skipped++
}
} else {
await tx.sharedList.create({
data: {
userId,
slug,
title: list.title,
description: list.description ?? null,
listType: list.listType,
isPublic: list.isPublic,
drinkIds: mappedDrinkIds,
},
})
summary.sharedLists.created++
}
}
}
return summary
},
{ timeout: 60000 }
)
}

121
src/lib/csv.ts Normal file
View File

@@ -0,0 +1,121 @@
/**
* CSV utilities — RFC 4180 compliant, no external dependencies.
*/
/** Escape a field value for CSV (wrap in quotes if needed) */
function escapeField(value: string): string {
if (
value.includes(",") ||
value.includes('"') ||
value.includes("\n") ||
value.includes("\r")
) {
return `"${value.replace(/"/g, '""')}"`
}
return value
}
/** Convert an array of objects to a CSV string */
export function objectsToCsv(
headers: string[],
rows: Record<string, string>[]
): string {
const lines: string[] = []
// Header row
lines.push(headers.map(escapeField).join(","))
// Data rows
for (const row of rows) {
const fields = headers.map((h) => escapeField(row[h] ?? ""))
lines.push(fields.join(","))
}
return lines.join("\n")
}
/**
* Parse a CSV string into an array of objects keyed by header names.
* Uses a state-machine parser to correctly handle quoted fields.
*/
export function csvToObjects(csvText: string): Record<string, string>[] {
const rows = parseCsvRows(csvText)
if (rows.length < 1) return []
const headers = rows[0]
const result: Record<string, string>[] = []
for (let i = 1; i < rows.length; i++) {
const row = rows[i]
// Skip empty rows
if (row.length === 1 && row[0] === "") continue
const obj: Record<string, string> = {}
for (let j = 0; j < headers.length; j++) {
obj[headers[j]] = row[j] ?? ""
}
result.push(obj)
}
return result
}
/** State-machine CSV parser that handles quoted fields correctly */
function parseCsvRows(text: string): string[][] {
const rows: string[][] = []
let currentRow: string[] = []
let currentField = ""
let inQuotes = false
for (let i = 0; i < text.length; i++) {
const char = text[i]
const nextChar = text[i + 1]
if (inQuotes) {
if (char === '"') {
if (nextChar === '"') {
// Escaped quote
currentField += '"'
i++ // skip next quote
} else {
// End of quoted field
inQuotes = false
}
} else {
currentField += char
}
} else {
if (char === '"' && currentField === "") {
// Start of quoted field
inQuotes = true
} else if (char === ",") {
currentRow.push(currentField)
currentField = ""
} else if (char === "\n") {
currentRow.push(currentField)
currentField = ""
rows.push(currentRow)
currentRow = []
} else if (char === "\r") {
// Skip carriage return (handle \r\n)
if (nextChar === "\n") {
i++ // skip \n
}
currentRow.push(currentField)
currentField = ""
rows.push(currentRow)
currentRow = []
} else {
currentField += char
}
}
}
// Handle last field/row
if (currentField !== "" || currentRow.length > 0) {
currentRow.push(currentField)
rows.push(currentRow)
}
return rows
}

33
src/lib/encryption.ts Normal file
View File

@@ -0,0 +1,33 @@
import { createCipheriv, createDecipheriv, randomBytes } from "crypto"
const ALGORITHM = "aes-256-cbc"
function getEncryptionKey(): Buffer {
const key = process.env.ENCRYPTION_KEY
if (!key) throw new Error("ENCRYPTION_KEY environment variable is required")
return Buffer.from(key, "hex")
}
export function encrypt(text: string): { encrypted: string; iv: string } {
const iv = randomBytes(16)
const cipher = createCipheriv(ALGORITHM, getEncryptionKey(), iv)
let encrypted = cipher.update(text, "utf8", "hex")
encrypted += cipher.final("hex")
return { encrypted, iv: iv.toString("hex") }
}
export function decrypt(encrypted: string, iv: string): string {
const decipher = createDecipheriv(
ALGORITHM,
getEncryptionKey(),
Buffer.from(iv, "hex")
)
let decrypted = decipher.update(encrypted, "hex", "utf8")
decrypted += decipher.final("utf8")
return decrypted
}
export function maskApiKey(key: string): string {
if (key.length <= 8) return "****"
return key.slice(0, 4) + "..." + key.slice(-4)
}

9
src/lib/prisma.ts Normal file
View File

@@ -0,0 +1,9 @@
import { PrismaClient } from "@prisma/client"
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma

34
src/lib/rate-limit.ts Normal file
View File

@@ -0,0 +1,34 @@
const rateLimitMap = new Map<string, { count: number; resetTime: number }>()
export function rateLimit(
key: string,
limit: number = 10,
windowMs: number = 60 * 1000
): { success: boolean; remaining: number } {
const now = Date.now()
const entry = rateLimitMap.get(key)
if (!entry || now > entry.resetTime) {
rateLimitMap.set(key, { count: 1, resetTime: now + windowMs })
return { success: true, remaining: limit - 1 }
}
if (entry.count >= limit) {
return { success: false, remaining: 0 }
}
entry.count++
return { success: true, remaining: limit - entry.count }
}
// Clean up expired entries periodically
if (typeof setInterval !== "undefined") {
setInterval(() => {
const now = Date.now()
rateLimitMap.forEach((entry, key) => {
if (now > entry.resetTime) {
rateLimitMap.delete(key)
}
})
}, 60 * 1000)
}

62
src/lib/s3.ts Normal file
View File

@@ -0,0 +1,62 @@
import {
S3Client,
PutObjectCommand,
GetObjectCommand,
DeleteObjectCommand,
} from "@aws-sdk/client-s3"
const s3Client = new S3Client({
endpoint: `http${process.env.MINIO_USE_SSL === "true" ? "s" : ""}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}`,
region: "us-east-1",
credentials: {
accessKeyId: process.env.MINIO_ACCESS_KEY!,
secretAccessKey: process.env.MINIO_SECRET_KEY!,
},
forcePathStyle: true,
})
const BUCKET = process.env.MINIO_BUCKET || "drink-images"
export async function uploadImage(
key: string,
body: Buffer,
contentType: string
): Promise<string> {
await s3Client.send(
new PutObjectCommand({
Bucket: BUCKET,
Key: key,
Body: body,
ContentType: contentType,
})
)
const useSSL = process.env.MINIO_USE_SSL === "true"
const protocol = useSSL ? "https" : "http"
return `${protocol}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}/${BUCKET}/${key}`
}
export async function getImage(key: string) {
const response = await s3Client.send(
new GetObjectCommand({
Bucket: BUCKET,
Key: key,
})
)
return response
}
export async function deleteImage(key: string) {
await s3Client.send(
new DeleteObjectCommand({
Bucket: BUCKET,
Key: key,
})
)
}
export function getImageUrl(key: string): string {
const useSSL = process.env.MINIO_USE_SSL === "true"
const protocol = useSSL ? "https" : "http"
return `${protocol}://${process.env.MINIO_ENDPOINT}:${process.env.MINIO_PORT}/${BUCKET}/${key}`
}

6
src/lib/utils.ts Normal file
View File

@@ -0,0 +1,6 @@
import { type ClassValue, clsx } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

73
src/lib/validators.ts Normal file
View File

@@ -0,0 +1,73 @@
import { z } from "zod"
export const drinkCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]),
subType: z.string().max(100).optional(),
brewery: z.string().max(200).optional(),
region: z.string().max(200).optional(),
abv: z.number().min(0).max(100).optional(),
description: z.string().max(2000).optional(),
imageUrl: z.string().url().optional(),
})
export const drinkUpdateSchema = drinkCreateSchema.partial()
export const ratingCreateSchema = z.object({
drinkId: z.string().min(1),
score: z.number().int().min(1).max(5),
notes: z.string().max(2000).optional(),
wouldReorder: z.boolean().optional(),
location: z.string().max(200).optional(),
})
export const ratingUpdateSchema = ratingCreateSchema.omit({ drinkId: true }).partial()
export const apiKeySchema = z.object({
provider: z.enum(["claude", "openai"]),
apiKey: z.string().min(1, "API key is required"),
label: z.string().max(100).optional(),
})
export const userPreferenceSchema = z.object({
preferredStyles: z.array(z.string().max(50)).max(20).optional(),
avoidedStyles: z.array(z.string().max(50)).max(20).optional(),
minAbv: z.number().min(0).max(100).optional().nullable(),
maxAbv: z.number().min(0).max(100).optional().nullable(),
defaultProvider: z.enum(["claude", "openai"]).optional().nullable(),
})
export const wishlistCreateSchema = z.object({
name: z.string().min(1, "Name is required").max(200),
type: z.enum(["BEER", "WINE", "COCKTAIL", "SPIRIT", "OTHER"]),
subType: z.string().max(100).optional(),
brewery: z.string().max(200).optional(),
abv: z.number().min(0).max(100).optional(),
description: z.string().max(2000).optional(),
notes: z.string().max(2000).optional(),
source: z.string().max(50).optional(),
})
export const sharedListCreateSchema = z.object({
title: z.string().min(1, "Title is required").max(200),
description: z.string().max(2000).optional(),
listType: z.enum(["collection", "wishlist", "custom"]).default("collection"),
isPublic: z.boolean().default(true),
drinkIds: z.array(z.string()).default([]),
})
export const sharedListUpdateSchema = z.object({
title: z.string().min(1).max(200).optional(),
description: z.string().max(2000).optional().nullable(),
isPublic: z.boolean().optional(),
})
export type DrinkCreate = z.infer<typeof drinkCreateSchema>
export type DrinkUpdate = z.infer<typeof drinkUpdateSchema>
export type RatingCreate = z.infer<typeof ratingCreateSchema>
export type RatingUpdate = z.infer<typeof ratingUpdateSchema>
export type ApiKeyInput = z.infer<typeof apiKeySchema>
export type UserPreferenceInput = z.infer<typeof userPreferenceSchema>
export type WishlistCreate = z.infer<typeof wishlistCreateSchema>
export type SharedListCreate = z.infer<typeof sharedListCreateSchema>
export type SharedListUpdate = z.infer<typeof sharedListUpdateSchema>