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

View File

@@ -0,0 +1,130 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { encrypt, decrypt, maskApiKey } from "@/lib/encryption"
import { apiKeySchema } from "@/lib/validators"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const apiKeys = await prisma.userApiKey.findMany({
where: { userId: session.user.id },
select: {
id: true,
provider: true,
label: true,
isActive: true,
createdAt: true,
updatedAt: true,
encryptedKey: true,
iv: true,
},
})
// Return masked keys
const maskedKeys = apiKeys.map((key) => {
let maskedKey = "****"
try {
const decrypted = decrypt(key.encryptedKey, key.iv)
maskedKey = maskApiKey(decrypted)
} catch {
// If decryption fails, show generic mask
}
return {
id: key.id,
provider: key.provider,
label: key.label,
isActive: key.isActive,
maskedKey,
createdAt: key.createdAt,
updatedAt: key.updatedAt,
}
})
return NextResponse.json(maskedKeys)
}
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const parsed = apiKeySchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
)
}
const { provider, apiKey, label } = parsed.data
const { encrypted, iv } = encrypt(apiKey)
const key = await prisma.userApiKey.upsert({
where: {
userId_provider: {
userId: session.user.id,
provider,
},
},
update: {
encryptedKey: encrypted,
iv,
label,
isActive: true,
},
create: {
userId: session.user.id,
provider,
encryptedKey: encrypted,
iv,
label,
isActive: true,
},
})
return NextResponse.json({
id: key.id,
provider: key.provider,
label: key.label,
maskedKey: maskApiKey(apiKey),
isActive: key.isActive,
})
} catch (error) {
console.error("API key save error:", error)
return NextResponse.json(
{ error: "Failed to save API key" },
{ status: 500 }
)
}
}
export async function DELETE(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const { searchParams } = new URL(request.url)
const provider = searchParams.get("provider")
if (!provider) {
return NextResponse.json({ error: "Provider required" }, { status: 400 })
}
await prisma.userApiKey.deleteMany({
where: {
userId: session.user.id,
provider,
},
})
return NextResponse.json({ success: true })
}

View File

@@ -0,0 +1,60 @@
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { generateBackupCsv } from "@/lib/backup"
import { NextResponse } from "next/server"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const userId = session.user.id
try {
const [drinks, ratings, wishlistItems, preferences, sharedLists] =
await Promise.all([
prisma.drink.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
prisma.rating.findMany({
where: { userId },
include: { drink: { select: { name: true } } },
orderBy: { createdAt: "asc" },
}),
prisma.wishlistItem.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
prisma.userPreference.findUnique({ where: { userId } }),
prisma.sharedList.findMany({
where: { userId },
orderBy: { createdAt: "asc" },
}),
])
const csv = generateBackupCsv(
drinks,
ratings,
wishlistItems,
preferences,
sharedLists
)
const date = new Date().toISOString().split("T")[0]
return new Response(csv, {
headers: {
"Content-Type": "text/csv; charset=utf-8",
"Content-Disposition": `attachment; filename="drinktracker-backup-${date}.csv"`,
},
})
} catch (error) {
console.error("Backup export error:", error)
return NextResponse.json(
{ error: "Failed to generate backup" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,61 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { prisma } from "@/lib/prisma"
import { userPreferenceSchema } from "@/lib/validators"
export async function GET() {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
const preferences = await prisma.userPreference.findUnique({
where: { userId: session.user.id },
})
return NextResponse.json(preferences || {
preferredStyles: [],
avoidedStyles: [],
minAbv: null,
maxAbv: null,
defaultProvider: null,
})
}
export async function PUT(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const body = await request.json()
const parsed = userPreferenceSchema.safeParse(body)
if (!parsed.success) {
return NextResponse.json(
{ error: "Invalid input", details: parsed.error.flatten() },
{ status: 400 }
)
}
const preferences = await prisma.userPreference.upsert({
where: { userId: session.user.id },
update: parsed.data,
create: {
userId: session.user.id,
...parsed.data,
preferredStyles: parsed.data.preferredStyles || [],
avoidedStyles: parsed.data.avoidedStyles || [],
},
})
return NextResponse.json(preferences)
} catch (error) {
console.error("Preferences save error:", error)
return NextResponse.json(
{ error: "Failed to save preferences" },
{ status: 500 }
)
}
}

View File

@@ -0,0 +1,99 @@
import { NextResponse } from "next/server"
import { auth } from "@/lib/auth"
import { csvToObjects } from "@/lib/csv"
import {
parseBackupRows,
validateBackupData,
executeRestore,
} from "@/lib/backup"
const VALID_MODES = ["merge-skip", "merge-update", "replace"] as const
export async function POST(request: Request) {
const session = await auth()
if (!session?.user?.id) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 })
}
try {
const formData = await request.formData()
const file = formData.get("file") as File | null
const mode = formData.get("mode") as string | null
if (!file) {
return NextResponse.json(
{ error: "No file provided" },
{ status: 400 }
)
}
if (!mode || !VALID_MODES.includes(mode as (typeof VALID_MODES)[number])) {
return NextResponse.json(
{ error: "Invalid restore mode. Must be: merge-skip, merge-update, or replace" },
{ status: 400 }
)
}
// Check file size (10MB max)
if (file.size > 10 * 1024 * 1024) {
return NextResponse.json(
{ error: "File too large. Maximum size is 10MB." },
{ status: 400 }
)
}
// Parse CSV
const csvText = await file.text()
if (!csvText.trim()) {
return NextResponse.json(
{ error: "File is empty" },
{ status: 400 }
)
}
const rows = csvToObjects(csvText)
if (rows.length === 0) {
return NextResponse.json(
{ error: "No data rows found in CSV" },
{ status: 400 }
)
}
// Check for _type column
if (!("_type" in rows[0])) {
return NextResponse.json(
{ error: "Invalid CSV format: missing _type column" },
{ status: 400 }
)
}
// Parse and validate
const parsed = parseBackupRows(rows)
const validation = validateBackupData(parsed)
if (!validation.valid) {
return NextResponse.json(
{
error: "Validation failed",
details: validation.errors.slice(0, 10).join("; "),
},
{ status: 400 }
)
}
// Execute restore
const summary = await executeRestore(
session.user.id,
parsed,
mode as (typeof VALID_MODES)[number]
)
return NextResponse.json({ success: true, summary })
} catch (error) {
console.error("Restore error:", error)
return NextResponse.json(
{ error: "Restore failed. Your data has not been changed." },
{ status: 500 }
)
}
}