Files
basil/packages/api/prisma/schema.prisma
Paul R Kartchner c3e3d66fef
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
feat: add family-based multi-tenant access control
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>
2026-04-17 08:08:10 -06:00

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
}