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:
130
src/app/api/settings/api-keys/route.ts
Normal file
130
src/app/api/settings/api-keys/route.ts
Normal 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 })
|
||||
}
|
||||
60
src/app/api/settings/backup/route.ts
Normal file
60
src/app/api/settings/backup/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
61
src/app/api/settings/preferences/route.ts
Normal file
61
src/app/api/settings/preferences/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
99
src/app/api/settings/restore/route.ts
Normal file
99
src/app/api/settings/restore/route.ts
Normal 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 }
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user