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:
209
src/lib/ai/base-provider.ts
Normal file
209
src/lib/ai/base-provider.ts
Normal 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"
|
||||
}
|
||||
}
|
||||
78
src/lib/ai/claude-provider.ts
Normal file
78
src/lib/ai/claude-provider.ts
Normal 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
291
src/lib/ai/menu-analyzer.ts
Normal 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)
|
||||
}
|
||||
81
src/lib/ai/openai-provider.ts
Normal file
81
src/lib/ai/openai-provider.ts
Normal 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
168
src/lib/ai/prompts.ts
Normal 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
|
||||
}
|
||||
]`
|
||||
}
|
||||
14
src/lib/ai/provider-factory.ts
Normal file
14
src/lib/ai/provider-factory.ts
Normal 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
69
src/lib/ai/types.ts
Normal 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
83
src/lib/auth.ts
Normal 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
713
src/lib/backup.ts
Normal 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
121
src/lib/csv.ts
Normal 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
33
src/lib/encryption.ts
Normal 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
9
src/lib/prisma.ts
Normal 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
34
src/lib/rate-limit.ts
Normal 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
62
src/lib/s3.ts
Normal 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
6
src/lib/utils.ts
Normal 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
73
src/lib/validators.ts
Normal 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>
|
||||
Reference in New Issue
Block a user