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,224 @@
-- CreateEnum
CREATE TYPE "DrinkType" AS ENUM ('BEER', 'WINE', 'COCKTAIL', 'SPIRIT', 'OTHER');
-- CreateEnum
CREATE TYPE "ScanStatus" AS ENUM ('UPLOADING', 'PROCESSING', 'COMPLETED', 'FAILED');
-- CreateTable
CREATE TABLE "User" (
"id" TEXT NOT NULL,
"name" TEXT,
"email" TEXT,
"emailVerified" TIMESTAMP(3),
"image" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "User_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Account" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"type" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"providerAccountId" TEXT NOT NULL,
"refresh_token" TEXT,
"access_token" TEXT,
"expires_at" INTEGER,
"token_type" TEXT,
"scope" TEXT,
"id_token" TEXT,
"session_state" TEXT,
CONSTRAINT "Account_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Session" (
"id" TEXT NOT NULL,
"sessionToken" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Session_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "VerificationToken" (
"identifier" TEXT NOT NULL,
"token" TEXT NOT NULL,
"expires" TIMESTAMP(3) NOT NULL
);
-- CreateTable
CREATE TABLE "UserApiKey" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"provider" TEXT NOT NULL,
"encryptedKey" TEXT NOT NULL,
"iv" TEXT NOT NULL,
"label" TEXT,
"isActive" BOOLEAN NOT NULL DEFAULT true,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserApiKey_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "UserPreference" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"preferredStyles" TEXT[],
"avoidedStyles" TEXT[],
"minAbv" DOUBLE PRECISION,
"maxAbv" DOUBLE PRECISION,
"defaultProvider" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "UserPreference_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Drink" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DrinkType" NOT NULL,
"subType" TEXT,
"brewery" TEXT,
"region" TEXT,
"abv" DOUBLE PRECISION,
"description" TEXT,
"imageUrl" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Drink_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "Rating" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"drinkId" TEXT NOT NULL,
"score" INTEGER NOT NULL,
"notes" TEXT,
"wouldReorder" BOOLEAN NOT NULL DEFAULT false,
"location" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "Rating_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MenuScan" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"imageUrl" TEXT NOT NULL,
"status" "ScanStatus" NOT NULL DEFAULT 'UPLOADING',
"aiProvider" TEXT,
"aiRawResponse" JSONB,
"errorMessage" TEXT,
"processedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "MenuScan_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "MenuItem" (
"id" TEXT NOT NULL,
"scanId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DrinkType" NOT NULL,
"subType" TEXT,
"brewery" TEXT,
"abv" DOUBLE PRECISION,
"price" TEXT,
"description" TEXT,
"matchedDrinkId" TEXT,
"userRating" INTEGER,
"aiRecommended" BOOLEAN NOT NULL DEFAULT false,
"aiReason" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "MenuItem_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "User_email_key" ON "User"("email");
-- CreateIndex
CREATE UNIQUE INDEX "Account_provider_providerAccountId_key" ON "Account"("provider", "providerAccountId");
-- CreateIndex
CREATE UNIQUE INDEX "Session_sessionToken_key" ON "Session"("sessionToken");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_token_key" ON "VerificationToken"("token");
-- CreateIndex
CREATE UNIQUE INDEX "VerificationToken_identifier_token_key" ON "VerificationToken"("identifier", "token");
-- CreateIndex
CREATE UNIQUE INDEX "UserApiKey_userId_provider_key" ON "UserApiKey"("userId", "provider");
-- CreateIndex
CREATE UNIQUE INDEX "UserPreference_userId_key" ON "UserPreference"("userId");
-- CreateIndex
CREATE INDEX "Drink_userId_idx" ON "Drink"("userId");
-- CreateIndex
CREATE INDEX "Drink_userId_type_idx" ON "Drink"("userId", "type");
-- CreateIndex
CREATE INDEX "Drink_userId_name_idx" ON "Drink"("userId", "name");
-- CreateIndex
CREATE INDEX "Rating_userId_idx" ON "Rating"("userId");
-- CreateIndex
CREATE INDEX "Rating_drinkId_idx" ON "Rating"("drinkId");
-- CreateIndex
CREATE INDEX "MenuScan_userId_idx" ON "MenuScan"("userId");
-- CreateIndex
CREATE INDEX "MenuItem_scanId_idx" ON "MenuItem"("scanId");
-- AddForeignKey
ALTER TABLE "Account" ADD CONSTRAINT "Account_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Session" ADD CONSTRAINT "Session_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserApiKey" ADD CONSTRAINT "UserApiKey_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "UserPreference" ADD CONSTRAINT "UserPreference_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Drink" ADD CONSTRAINT "Drink_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Rating" ADD CONSTRAINT "Rating_drinkId_fkey" FOREIGN KEY ("drinkId") REFERENCES "Drink"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuScan" ADD CONSTRAINT "MenuScan_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_scanId_fkey" FOREIGN KEY ("scanId") REFERENCES "MenuScan"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "MenuItem" ADD CONSTRAINT "MenuItem_matchedDrinkId_fkey" FOREIGN KEY ("matchedDrinkId") REFERENCES "Drink"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,54 @@
-- AlterTable
ALTER TABLE "User" ADD COLUMN "password" TEXT;
-- CreateTable
CREATE TABLE "WishlistItem" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"name" TEXT NOT NULL,
"type" "DrinkType" NOT NULL,
"subType" TEXT,
"brewery" TEXT,
"abv" DOUBLE PRECISION,
"description" TEXT,
"notes" TEXT,
"source" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "WishlistItem_pkey" PRIMARY KEY ("id")
);
-- CreateTable
CREATE TABLE "SharedList" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"slug" TEXT NOT NULL,
"title" TEXT NOT NULL,
"description" TEXT,
"listType" TEXT NOT NULL DEFAULT 'collection',
"isPublic" BOOLEAN NOT NULL DEFAULT true,
"drinkIds" TEXT[],
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "SharedList_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "WishlistItem_userId_idx" ON "WishlistItem"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SharedList_slug_key" ON "SharedList"("slug");
-- CreateIndex
CREATE INDEX "SharedList_userId_idx" ON "SharedList"("userId");
-- CreateIndex
CREATE INDEX "SharedList_slug_idx" ON "SharedList"("slug");
-- AddForeignKey
ALTER TABLE "WishlistItem" ADD CONSTRAINT "WishlistItem_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SharedList" ADD CONSTRAINT "SharedList_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,18 @@
-- CreateTable
CREATE TABLE "SearchCache" (
"id" TEXT NOT NULL,
"userId" TEXT NOT NULL,
"queryHash" TEXT NOT NULL,
"query" TEXT NOT NULL,
"results" JSONB NOT NULL,
"provider" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "SearchCache_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "SearchCache_userId_idx" ON "SearchCache"("userId");
-- CreateIndex
CREATE UNIQUE INDEX "SearchCache_userId_queryHash_provider_key" ON "SearchCache"("userId", "queryHash", "provider");

View File

@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"

251
prisma/schema.prisma Normal file
View File

@@ -0,0 +1,251 @@
generator client {
provider = "prisma-client-js"
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
// ─── Auth.js Models ──────────────────────────────────────────────
model User {
id String @id @default(cuid())
name String?
email String? @unique
emailVerified DateTime?
image String?
password String? // hashed password for credentials auth
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
accounts Account[]
sessions Session[]
drinks Drink[]
ratings Rating[]
menuScans MenuScan[]
apiKeys UserApiKey[]
preferences UserPreference?
wishlistItems WishlistItem[]
sharedLists SharedList[]
}
model Account {
id String @id @default(cuid())
userId String
type String
provider String
providerAccountId String
refresh_token String? @db.Text
access_token String? @db.Text
expires_at Int?
token_type String?
scope String?
id_token String? @db.Text
session_state String?
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([provider, providerAccountId])
}
model Session {
id String @id @default(cuid())
sessionToken String @unique
userId String
expires DateTime
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
model VerificationToken {
identifier String
token String @unique
expires DateTime
@@unique([identifier, token])
}
// ─── App Models ──────────────────────────────────────────────────
model UserApiKey {
id String @id @default(cuid())
userId String
provider String // "claude" | "openai"
encryptedKey String @db.Text
iv String // initialization vector for decryption
label String? // optional user-friendly label
isActive Boolean @default(true)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([userId, provider])
}
model UserPreference {
id String @id @default(cuid())
userId String @unique
preferredStyles String[] // e.g., ["IPA", "Stout", "Pinot Noir"]
avoidedStyles String[] // e.g., ["Sour", "Light Lager"]
minAbv Float?
maxAbv Float?
defaultProvider String? // preferred AI provider
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
}
enum DrinkType {
BEER
WINE
COCKTAIL
SPIRIT
OTHER
}
model Drink {
id String @id @default(cuid())
userId String
name String
type DrinkType
subType String? // e.g., "IPA", "Stout", "Cabernet Sauvignon"
brewery String? // brewery or winery
region String?
abv Float?
description String? @db.Text
imageUrl String?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
ratings Rating[]
menuItems MenuItem[]
@@index([userId])
@@index([userId, type])
@@index([userId, name])
}
model Rating {
id String @id @default(cuid())
userId String
drinkId String
score Int // 1-5
notes String? @db.Text
wouldReorder Boolean @default(false)
location String? // where they tried it
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
drink Drink @relation(fields: [drinkId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([drinkId])
}
enum ScanStatus {
UPLOADING
PROCESSING
COMPLETED
FAILED
}
model MenuScan {
id String @id @default(cuid())
userId String
imageUrl String
status ScanStatus @default(UPLOADING)
aiProvider String? // which provider was used
aiRawResponse Json? // raw AI response for debugging
errorMessage String? @db.Text
processedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
items MenuItem[]
@@index([userId])
}
model MenuItem {
id String @id @default(cuid())
scanId String
name String
type DrinkType
subType String?
brewery String?
abv Float?
price String?
description String? @db.Text
matchedDrinkId String?
userRating Int? // cached rating from matched drink
aiRecommended Boolean @default(false)
aiReason String? @db.Text // why AI recommends it
createdAt DateTime @default(now())
scan MenuScan @relation(fields: [scanId], references: [id], onDelete: Cascade)
matchedDrink Drink? @relation(fields: [matchedDrinkId], references: [id], onDelete: SetNull)
@@index([scanId])
}
// ─── Wishlist / Try Later ────────────────────────────────────────
model WishlistItem {
id String @id @default(cuid())
userId String
name String
type DrinkType
subType String?
brewery String?
abv Float?
description String? @db.Text
notes String? @db.Text // user's personal note ("saw at Joe's bar")
source String? // "scan", "ai_search", "manual"
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
}
// ─── Search Cache ───────────────────────────────────────────────
model SearchCache {
id String @id @default(cuid())
userId String
queryHash String // normalized (lowercase, trimmed)
query String // original text
results Json // { drinks: [...] }
provider String // "claude" | "openai"
createdAt DateTime @default(now())
@@unique([userId, queryHash, provider])
@@index([userId])
}
// ─── Shared Lists ────────────────────────────────────────────────
model SharedList {
id String @id @default(cuid())
userId String
slug String @unique // public URL slug
title String
description String? @db.Text
listType String @default("collection") // "collection", "wishlist", "custom"
isPublic Boolean @default(true)
drinkIds String[] // ids of drinks to include (empty = all)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([slug])
}