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
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>
250 lines
6.9 KiB
Plaintext
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])
|
|
}
|