* Add signup/login (profile creation + JWT auth) API: - POST /auth/signup — creates a house + user_profile (transactional), hashes the password with argon2, sets a JWT in an httpOnly cookie - POST /auth/login — verifies credentials (generic 401 for both wrong email and wrong password, doesn't leak which), sets the cookie - POST /auth/logout — clears the cookie - GET /auth/me — current profile, behind requireAuth middleware - requireAuth verifies the JWT and re-checks tokenVersion against the DB, so a stateless JWT can still be invalidated (password change / logout-everywhere, not built yet but the field is in place) Schema: user_profiles gets password_hash + token_version (not in the original spec doc — required for auth). New migration, with COMMENT ON for the new columns per the established pattern. Decisions from the auth planning discussion: JWT in httpOnly cookie (not server-side sessions), first profile created also creates its house, argon2 for hashing. argon2 pinned to 0.31.2 (not ^, deliberately): 0.45.1 segfaults at runtime on this Windows machine — reproduced consistently across bash (sandboxed and unsandboxed) and PowerShell, while 0.31.2 works fine with the same API. Documented in the README as a trap for future upgrades, since `tsc`/`prisma generate` succeeding doesn't catch a runtime native-binding crash. Tests: Mocha (unit-style, apps/api/test/auth.test.ts) and a Cucumber feature (apps/api/features/auth.feature) covering the full signup → authenticated flow, duplicate email, wrong password. Both share test-support/reset-db.ts (TRUNCATE ... CASCADE) to start each test/scenario from a clean slate. Test-only argon2 cost parameters (NODE_ENV=test) keep the suite fast — argon2's real cost is deliberately expensive, which made hashing dozens of times per run slow and occasionally timeout-flaky at default cost. CI: added a Postgres service container to lint-and-test (previously none — tests didn't touch a real DB), runs `prisma migrate deploy` before the test steps. Verified end-to-end manually against the dev server (curl): signup, duplicate email (409), wrong password (401), valid login (200), validation errors (400), /me with and without cookie, logout (204) — all behave as intended. Full suite (lint, mocha, cucumber, build) run multiple times locally with no flakiness after the timeout/cost fixes. * Fix CI: generate Prisma Client via postinstall CI failed with "@prisma/client did not initialize yet" — pnpm install never ran `prisma generate`, and `prisma migrate deploy` (unlike `migrate dev`) doesn't do it either. Worked locally only because prior `prisma migrate dev` runs had already generated the client as a side effect. Adding a postinstall script fixes it for CI and for anyone cloning the repo fresh and running plain `pnpm install`.
215 lines
6.5 KiB
Text
215 lines
6.5 KiB
Text
generator client {
|
|
provider = "prisma-client-js"
|
|
}
|
|
|
|
datasource db {
|
|
provider = "postgresql"
|
|
url = env("DATABASE_URL")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Users & household
|
|
// See specs/batch-cooking-modele.md for the source data model documentation.
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model House {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
|
|
members UserProfile[]
|
|
plannings Planning[]
|
|
|
|
@@map("house")
|
|
}
|
|
|
|
model Diet {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
|
|
users UserProfile[]
|
|
|
|
@@map("diet")
|
|
}
|
|
|
|
/// Enumeration-style table, meant to grow over time (e.g. allergy nuances).
|
|
model Category {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
|
|
allergies Allergy[]
|
|
|
|
@@map("category")
|
|
}
|
|
|
|
model Allergy {
|
|
id Int @id @default(autoincrement())
|
|
categoryId Int @map("cat_id")
|
|
|
|
category Category @relation(fields: [categoryId], references: [id])
|
|
users UserProfileAllergy[]
|
|
|
|
@@map("allergy")
|
|
}
|
|
|
|
model UserProfile {
|
|
id Int @id @default(autoincrement())
|
|
firstName String @map("first_name")
|
|
lastName String @map("last_name")
|
|
email String @unique
|
|
/// argon2 hash of the account password. Not in the original spec doc —
|
|
/// added for authentication (login page / profile creation).
|
|
passwordHash String @map("password_hash")
|
|
/// Bumped to invalidate previously-issued JWTs (e.g. on password change).
|
|
/// Not in the original spec doc — required for stateless JWT auth.
|
|
tokenVersion Int @default(0) @map("token_version")
|
|
houseId Int? @map("house_id")
|
|
dietId Int? @map("diet_id")
|
|
|
|
house House? @relation(fields: [houseId], references: [id], onDelete: SetNull)
|
|
diet Diet? @relation(fields: [dietId], references: [id], onDelete: SetNull)
|
|
allergies UserProfileAllergy[]
|
|
|
|
@@map("user_profiles")
|
|
}
|
|
|
|
/// Explicit join table for the user_profiles <-> allergy association
|
|
/// (documented in the spec as a plain many-to-many, no extra fields).
|
|
model UserProfileAllergy {
|
|
userProfileId Int @map("user_profile_id")
|
|
allergyId Int @map("allergy_id")
|
|
|
|
userProfile UserProfile @relation(fields: [userProfileId], references: [id], onDelete: Cascade)
|
|
allergy Allergy @relation(fields: [allergyId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([userProfileId, allergyId])
|
|
@@map("user_profile_allergy")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Planning
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model Planning {
|
|
id Int @id @default(autoincrement())
|
|
startDate DateTime @map("start_date") @db.Date
|
|
finishDate DateTime @map("finish_date") @db.Date
|
|
houseId Int @map("house_id")
|
|
|
|
house House @relation(fields: [houseId], references: [id], onDelete: Cascade)
|
|
items PlanningItem[]
|
|
|
|
@@map("planning")
|
|
}
|
|
|
|
model PlanningItem {
|
|
id Int @id @default(autoincrement())
|
|
planningId Int @map("planning_id")
|
|
weekDay String @map("week_day")
|
|
meal String
|
|
recipeId Int @map("recipe_id")
|
|
|
|
planning Planning @relation(fields: [planningId], references: [id], onDelete: Cascade)
|
|
recipe Recipe @relation(fields: [recipeId], references: [id])
|
|
|
|
@@map("planning_item")
|
|
}
|
|
|
|
// -----------------------------------------------------------------------------
|
|
// Recipes
|
|
// -----------------------------------------------------------------------------
|
|
|
|
model Source {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
url String?
|
|
|
|
recipes Recipe[]
|
|
|
|
@@map("sources")
|
|
}
|
|
|
|
model Recipe {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
sourceId Int? @map("source_id")
|
|
description String?
|
|
picture String?
|
|
|
|
source Source? @relation(fields: [sourceId], references: [id], onDelete: SetNull)
|
|
ingredients RecipeIngredient[]
|
|
steps Step[]
|
|
planningItems PlanningItem[]
|
|
/// Ingredients for which this recipe is offered as a make-it-yourself alternative.
|
|
alternateFor Ingredient[] @relation("IngredientAlternateRecipe")
|
|
|
|
@@map("recipe")
|
|
}
|
|
|
|
model Ingredient {
|
|
id Int @id @default(autoincrement())
|
|
name String
|
|
icon String?
|
|
alternateRecipeId Int? @map("alternate_recipe")
|
|
|
|
alternateRecipe Recipe? @relation("IngredientAlternateRecipe", fields: [alternateRecipeId], references: [id], onDelete: SetNull)
|
|
recipes RecipeIngredient[]
|
|
|
|
@@map("ingredients")
|
|
}
|
|
|
|
/// recipe <-> ingredients association. The spec documents this as a plain
|
|
/// many-to-many, but a shopping list / batch-cooking calculation needs a
|
|
/// quantity per recipe, so this join table carries quantity + unit
|
|
/// (project decision, not in the original spec doc).
|
|
model RecipeIngredient {
|
|
recipeId Int @map("recipe_id")
|
|
ingredientId Int @map("ingredient_id")
|
|
quantity Decimal @db.Decimal(10, 2)
|
|
unit String
|
|
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
ingredient Ingredient @relation(fields: [ingredientId], references: [id], onDelete: Cascade)
|
|
|
|
@@id([recipeId, ingredientId])
|
|
@@map("recipe_ingredient")
|
|
}
|
|
|
|
model TechStep {
|
|
id Int @id @default(autoincrement())
|
|
|
|
steps Step[]
|
|
mappings TechStepMapping[]
|
|
|
|
@@map("tech_step")
|
|
}
|
|
|
|
/// Used by the recipe-import pipeline to auto-detect which technique a raw
|
|
/// instruction step corresponds to (expression = text pattern, weight = match score).
|
|
model TechStepMapping {
|
|
id Int @id @default(autoincrement())
|
|
techStepId Int @map("tech_step_id")
|
|
expression String
|
|
weight Int
|
|
|
|
techStep TechStep @relation(fields: [techStepId], references: [id], onDelete: Cascade)
|
|
|
|
@@map("tech_step_mapping")
|
|
}
|
|
|
|
/// Modeled as one-to-many (a step belongs to exactly one recipe), not the
|
|
/// many-to-many noted in the spec doc: `order` only makes sense scoped to a
|
|
/// single recipe, which isn't reconcilable with steps being shared across
|
|
/// recipes. See specs/batch-cooking-modele.md for the original wording.
|
|
model Step {
|
|
id Int @id @default(autoincrement())
|
|
recipeId Int @map("recipe_id")
|
|
description String
|
|
picture String?
|
|
order Int
|
|
techStepId Int? @map("tech_step_id")
|
|
|
|
recipe Recipe @relation(fields: [recipeId], references: [id], onDelete: Cascade)
|
|
techStep TechStep? @relation(fields: [techStepId], references: [id], onDelete: SetNull)
|
|
|
|
@@map("step")
|
|
}
|