Some checks failed
Basil CI/CD Pipeline / Code Linting (push) Successful in 3m18s
Basil CI/CD Pipeline / Web Tests (push) Successful in 3m31s
Basil CI/CD Pipeline / Security Scanning (push) Has been cancelled
Basil CI/CD Pipeline / API Tests (push) Failing after 3m56s
Basil CI/CD Pipeline / Shared Package Tests (push) Successful in 3m11s
Basil CI/CD Pipeline / Trigger Deployment (push) Has been cancelled
Basil CI/CD Pipeline / Build All Packages (push) Has been cancelled
Basil CI/CD Pipeline / E2E Tests (push) Has been cancelled
Basil CI/CD Pipeline / Build & Push Docker Images (push) Has been cancelled
Introduces Family as the tenant boundary so recipes and cookbooks can be scoped per household instead of every user seeing everything. Adds a centralized access filter, an invite/membership UI, a first-login prompt to create a family, and locks down the previously unauthenticated backup routes to admin only. - Family and FamilyMember models with OWNER/MEMBER roles; familyId on Recipe and Cookbook (ON DELETE SET NULL so deleting a family orphans content rather than destroying it). - access.service.ts composes a single WhereInput covering owner, family, PUBLIC visibility, and direct share; admins short-circuit to full access. - recipes/cookbooks routes now require auth, strip client-supplied userId/familyId on create, and gate mutations with canMutate checks. Auto-filter helpers scoped to the same family to prevent cross-tenant leakage via shared tag names. - families.routes.ts exposes list/create/get/rename/delete plus add/remove member, with last-owner protection on removal. - FamilyGate component blocks the authenticated UI with a modal if the user has zero memberships, prompting them to create their first family; Family page provides ongoing management. - backup.routes.ts now requires admin; it had no auth at all before. - Bumps version to 2026.04.008 and documents the monotonic PPP counter in CLAUDE.md. Migration SQL is generated locally but not tracked (per existing .gitignore); apply 20260416010000_add_family_tenant to prod during deploy. Run backfill-family-tenant.ts once post-migration to assign existing content to a default owner's family. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
376 lines
11 KiB
Plaintext
376 lines
11 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[]
|
|
mealPlans MealPlan[]
|
|
familyMemberships FamilyMember[]
|
|
|
|
@@index([email])
|
|
@@index([provider, providerId])
|
|
}
|
|
|
|
enum FamilyRole {
|
|
OWNER
|
|
MEMBER
|
|
}
|
|
|
|
model Family {
|
|
id String @id @default(cuid())
|
|
name String
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
members FamilyMember[]
|
|
recipes Recipe[]
|
|
cookbooks Cookbook[]
|
|
|
|
@@index([name])
|
|
}
|
|
|
|
model FamilyMember {
|
|
id String @id @default(cuid())
|
|
userId String
|
|
familyId String
|
|
role FamilyRole @default(MEMBER)
|
|
joinedAt DateTime @default(now())
|
|
|
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
|
family Family @relation(fields: [familyId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([userId, familyId])
|
|
@@index([userId])
|
|
@@index([familyId])
|
|
}
|
|
|
|
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 (creator)
|
|
familyId String? // Owning family (tenant scope)
|
|
visibility Visibility @default(PRIVATE)
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
|
sections RecipeSection[]
|
|
ingredients Ingredient[]
|
|
instructions Instruction[]
|
|
images RecipeImage[]
|
|
tags RecipeTag[]
|
|
cookbooks CookbookRecipe[]
|
|
sharedWith RecipeShare[]
|
|
meals MealRecipe[]
|
|
|
|
@@index([title])
|
|
@@index([cuisine])
|
|
@@index([userId])
|
|
@@index([familyId])
|
|
@@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[]
|
|
cookbooks CookbookTag[]
|
|
}
|
|
|
|
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 CookbookTag {
|
|
cookbookId String
|
|
tagId String
|
|
|
|
cookbook Cookbook @relation(fields: [cookbookId], references: [id], onDelete: Cascade)
|
|
tag Tag @relation(fields: [tagId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([cookbookId, tagId])
|
|
@@index([cookbookId])
|
|
@@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 (creator)
|
|
familyId String? // Owning family (tenant scope)
|
|
autoFilterCategories String[] @default([]) // Auto-add recipes matching these categories
|
|
autoFilterTags String[] @default([]) // Auto-add recipes matching these tags
|
|
autoFilterCookbookTags String[] @default([]) // Auto-add cookbooks matching these tags
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
family Family? @relation(fields: [familyId], references: [id], onDelete: SetNull)
|
|
recipes CookbookRecipe[]
|
|
tags CookbookTag[]
|
|
includedCookbooks CookbookInclusion[] @relation("ParentCookbook")
|
|
includedIn CookbookInclusion[] @relation("ChildCookbook")
|
|
|
|
@@index([name])
|
|
@@index([userId])
|
|
@@index([familyId])
|
|
}
|
|
|
|
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])
|
|
}
|
|
|
|
model CookbookInclusion {
|
|
id String @id @default(cuid())
|
|
parentCookbookId String
|
|
childCookbookId String
|
|
addedAt DateTime @default(now())
|
|
|
|
parentCookbook Cookbook @relation("ParentCookbook", fields: [parentCookbookId], references: [id], onDelete: Cascade)
|
|
childCookbook Cookbook @relation("ChildCookbook", fields: [childCookbookId], references: [id], onDelete: Cascade)
|
|
|
|
@@unique([parentCookbookId, childCookbookId])
|
|
@@index([parentCookbookId])
|
|
@@index([childCookbookId])
|
|
}
|
|
|
|
model MealPlan {
|
|
id String @id @default(cuid())
|
|
userId String?
|
|
date DateTime // The day this meal plan is for (stored at midnight UTC)
|
|
notes String? @db.Text // Optional notes for the entire day
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
user User? @relation(fields: [userId], references: [id], onDelete: SetNull)
|
|
meals Meal[]
|
|
|
|
@@unique([userId, date]) // One meal plan per user per day
|
|
@@index([userId])
|
|
@@index([date])
|
|
@@index([userId, date])
|
|
}
|
|
|
|
model Meal {
|
|
id String @id @default(cuid())
|
|
mealPlanId String
|
|
mealType MealType
|
|
order Int // Order within the same meal type (for multi-recipe meals)
|
|
servings Int? // Servings for this specific meal (can override recipe default)
|
|
notes String? @db.Text // Meal-specific notes
|
|
createdAt DateTime @default(now())
|
|
updatedAt DateTime @updatedAt
|
|
|
|
mealPlan MealPlan @relation(fields: [mealPlanId], references: [id], onDelete: Cascade)
|
|
recipe MealRecipe?
|
|
|
|
@@index([mealPlanId])
|
|
@@index([mealType])
|
|
}
|
|
|
|
model MealRecipe {
|
|
mealId String @id
|
|
recipeId String
|
|
|
|
meal Meal @relation(fields: [mealId], references: [id], onDelete: Cascade)
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
|
|
@@index([recipeId])
|
|
}
|
|
|
|
enum MealType {
|
|
BREAKFAST
|
|
LUNCH
|
|
DINNER
|
|
SNACK
|
|
DESSERT
|
|
OTHER
|
|
}
|