Files
basil/packages/api/prisma/schema.prisma
Paul R Kartchner 2d53b9e283
Some checks failed
CI Pipeline / Lint Code (push) Has been cancelled
CI Pipeline / Test API Package (push) Has been cancelled
CI Pipeline / Test Web Package (push) Has been cancelled
CI Pipeline / Test Shared Package (push) Has been cancelled
CI Pipeline / Build All Packages (push) Has been cancelled
CI Pipeline / Generate Coverage Report (push) Has been cancelled
Docker Build & Deploy / Build Docker Images (push) Has been cancelled
Docker Build & Deploy / Push Docker Images (push) Has been cancelled
Docker Build & Deploy / Deploy to Staging (push) Has been cancelled
Docker Build & Deploy / Deploy to Production (push) Has been cancelled
E2E Tests / End-to-End Tests (push) Has been cancelled
E2E Tests / E2E Tests (Mobile) (push) Has been cancelled
Security Scanning / NPM Audit (push) Has been cancelled
Security Scanning / Dependency License Check (push) Has been cancelled
Security Scanning / Code Quality Scan (push) Has been cancelled
Security Scanning / Docker Image Security (push) Has been cancelled
Security Scanning / Security Summary (push) Has been cancelled
feat: add comprehensive authentication system with JWT and OAuth
Implement a complete authentication system with local email/password
authentication, Google OAuth, JWT tokens, and role-based access control.

Backend Features:
- Database schema with User, RefreshToken, VerificationToken, RecipeShare models
- Role-based access control (USER, ADMIN)
- Recipe visibility controls (PRIVATE, SHARED, PUBLIC)
- Email verification for local accounts
- Password reset functionality
- JWT access tokens (15min) and refresh tokens (7 days)
- Passport.js strategies: Local, JWT, Google OAuth
- bcrypt password hashing with 12 salt rounds
- Password strength validation (min 8 chars, uppercase, lowercase, number)
- Rate limiting on auth endpoints (5 attempts/15min)
- Email service with styled HTML templates for verification and password reset

API Endpoints:
- POST /api/auth/register - Register with email/password
- POST /api/auth/login - Login and get tokens
- POST /api/auth/logout - Invalidate refresh token
- POST /api/auth/refresh - Get new access token
- GET /api/auth/verify-email/:token - Verify email address
- POST /api/auth/resend-verification - Resend verification email
- POST /api/auth/forgot-password - Request password reset
- POST /api/auth/reset-password - Reset password with token
- GET /api/auth/google - Initiate Google OAuth
- GET /api/auth/google/callback - Google OAuth callback
- GET /api/auth/me - Get current user info

Security Middleware:
- requireAuth - Protect routes requiring authentication
- requireAdmin - Admin-only route protection
- optionalAuth - Routes that work with or without auth
- requireOwnership - Check resource ownership

Admin Tools:
- npm run create-admin - Interactive script to create admin users
- verify-user-manual.ts - Helper script for testing

Test Coverage:
- 49 unit and integration tests (all passing)
- Password utility tests (12 tests)
- JWT utility tests (17 tests)
- Auth middleware tests (12 tests)
- Auth routes integration tests (8 tests)

Dependencies Added:
- passport, passport-local, passport-jwt, passport-google-oauth20
- bcrypt, jsonwebtoken
- nodemailer
- express-rate-limit, express-validator, cookie-parser

Environment Variables Required:
- JWT_SECRET, JWT_REFRESH_SECRET
- JWT_EXPIRES_IN, JWT_REFRESH_EXPIRES_IN
- GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET (optional)
- SMTP configuration for email
- APP_URL, API_URL

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-11-25 04:37:05 +00:00

250 lines
6.9 KiB
Plaintext

generator client {
provider = "prisma-client-js"
binaryTargets = ["native", "linux-musl-openssl-3.0.x"]
}
datasource db {
provider = "postgresql"
url = env("DATABASE_URL")
}
model User {
id String @id @default(cuid())
email String @unique
username String? @unique
passwordHash String? // null for OAuth-only users
name String?
avatar String?
provider String @default("local") // "local", "google", "github", etc.
providerId String? // OAuth provider's user ID
role Role @default(USER)
emailVerified Boolean @default(false)
emailVerifiedAt DateTime?
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
recipes Recipe[]
cookbooks Cookbook[]
sharedRecipes RecipeShare[]
refreshTokens RefreshToken[]
verificationTokens VerificationToken[]
@@index([email])
@@index([provider, providerId])
}
model VerificationToken {
id String @id @default(cuid())
userId String
token String @unique
type TokenType
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
}
enum TokenType {
EMAIL_VERIFICATION
PASSWORD_RESET
}
enum Role {
USER
ADMIN
}
enum Visibility {
PRIVATE // Only owner can see
SHARED // Owner + specific users
PUBLIC // Everyone can see
}
model RefreshToken {
id String @id @default(cuid())
userId String
token String @unique
expiresAt DateTime
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@index([userId])
@@index([token])
}
model Recipe {
id String @id @default(cuid())
title String
description String?
prepTime Int? // minutes
cookTime Int? // minutes
totalTime Int? // minutes
servings Int?
imageUrl String?
sourceUrl String? // For imported recipes
author String?
cuisine String?
categories String[] @default([]) // Changed from single category to array
rating Float?
userId String? // Recipe owner
visibility Visibility @default(PRIVATE)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
sections RecipeSection[]
ingredients Ingredient[]
instructions Instruction[]
images RecipeImage[]
tags RecipeTag[]
cookbooks CookbookRecipe[]
sharedWith RecipeShare[]
@@index([title])
@@index([cuisine])
@@index([userId])
@@index([visibility])
}
model RecipeSection {
id String @id @default(cuid())
recipeId String
name String // e.g., "Starter", "Dough", "Assembly"
order Int
timing String? // e.g., "Day 1 - 8PM", "12 hours before mixing"
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
ingredients Ingredient[]
instructions Instruction[]
@@index([recipeId])
}
model Ingredient {
id String @id @default(cuid())
recipeId String? // Optional - can be derived from section
sectionId String? // Optional - if null, belongs to recipe directly
name String
amount String?
unit String?
notes String?
order Int
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
instructions IngredientInstructionMapping[]
@@index([recipeId])
@@index([sectionId])
}
model Instruction {
id String @id @default(cuid())
recipeId String? // Optional - can be derived from section
sectionId String? // Optional - if null, belongs to recipe directly
step Int
text String @db.Text
imageUrl String?
timing String? // e.g., "8:00am", "After 30 minutes", "Day 2 - Morning"
recipe Recipe? @relation(fields: [recipeId], references: [id], onDelete: Cascade)
section RecipeSection? @relation(fields: [sectionId], references: [id], onDelete: Cascade)
ingredients IngredientInstructionMapping[]
@@index([recipeId])
@@index([sectionId])
}
model IngredientInstructionMapping {
id String @id @default(cuid())
ingredientId String
instructionId String
order Int // Display order within the instruction
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
instruction Instruction @relation(fields: [instructionId], references: [id], onDelete: Cascade)
@@unique([ingredientId, instructionId])
@@index([instructionId])
@@index([ingredientId])
}
model RecipeImage {
id String @id @default(cuid())
recipeId String
url String
order Int
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@index([recipeId])
}
model Tag {
id String @id @default(cuid())
name String @unique
recipes RecipeTag[]
}
model RecipeTag {
recipeId String
tagId String
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
@@id([recipeId, tagId])
@@index([recipeId])
@@index([tagId])
}
model RecipeShare {
id String @id @default(cuid())
recipeId String
userId String
createdAt DateTime @default(now())
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
@@unique([recipeId, userId])
@@index([recipeId])
@@index([userId])
}
model Cookbook {
id String @id @default(cuid())
name String
description String?
coverImageUrl String?
userId String? // Cookbook owner
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
recipes CookbookRecipe[]
@@index([name])
@@index([userId])
}
model CookbookRecipe {
id String @id @default(cuid())
cookbookId String
recipeId String
addedAt DateTime @default(now())
cookbook Cookbook @relation(fields: [cookbookId], references: [id], onDelete: Cascade)
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
@@unique([cookbookId, recipeId])
@@index([cookbookId])
@@index([recipeId])
}