Data model

Recipe app · PostgreSQL 16 · Normalized schema with audit trails

v1.1
Engine: PostgreSQL 16
Tables: 18
Domains: 6
Designed by: Atlas

v1.1 changes from v1.0

Overview
This schema covers all six user journeys (J1–J6), the suggestion/variety engine, the lightweight pantry tracker, recipe hero images, and platform-level admin user management. It is normalized by default, with computed fields (variety score) calculated at query time rather than stored. Every mutable table carries audit timestamps. Tags use a proper M:N relationship via a reference table + junction table.
4
Auth & household
7
Recipe domain
3
Planning domain
2
Shopping domain
1
Pantry domain
1
Admin domain

Design decisions

Variety score is computed, not stored — it's derived from cooking_log + recipe_ingredient + week_plan_slot.
Ingredients are a normalized reference table — enables merging, repetition tracking, and staple filtering.
Tags are a proper M:N: a tag reference table + recipe_tag junction. One recipe → many tags, one tag → many recipes. Rename once, applies everywhere.
Ingredient categories are a normalized 1:N reference table — one ingredient belongs to one category (e.g. "Produce", "Fish & Meat"). Rename a category once, applies to all ingredients. Powers the aisle-grouped shopping list (J5 variant V2).
Hero images store a URL/path reference to object storage (S3/R2).
Admin uses a system_role on user_account (not the household role). Admin actions are audit-logged in a dedicated table.
Pantry items link to the shared ingredient reference with best-before dates.

Entity-relationship diagram
Entities grouped by domain. Purple = auth, green = recipe, yellow = planning, blue = shopping, orange = pantry, red = admin. NEW marks v1.1 additions/changes.
user_account CHANGED
PK iduuid
emailcitext UNIQUE
display_namevarchar(100)
password_hashvarchar(255)
system_roleenum(admin,user) NEW
is_activeboolean NEW
created_attimestamptz
updated_attimestamptz
household
PK iduuid
namevarchar(100)
FK created_by→ user_account
created_attimestamptz
household_member
PK iduuid
FK household_id→ household
FK user_id→ user_account UNIQUE
roleenum(planner,member)
joined_attimestamptz
household_invite
PK iduuid
FK household_id→ household
invite_codevarchar(20) UNIQUE
statusenum(pending,used,expired)
expires_attimestamptz
recipe
PK iduuid
FK household_id→ household
namevarchar(200)
servessmallint
cook_time_minsmallint
effortenum(easy,medium,hard)
is_child_friendlyboolean
hero_image_urlvarchar(500) NULL
deleted_attimestamptz NULL
ingredient CHANGED
PK iduuid
FK household_id→ household
namecitext
is_stapleboolean
FK category_id→ ingredient_category NULL NEW
ingredient_category NEW
PK iduuid
FK household_id→ household
namecitext
sort_ordersmallint
recipe_ingredient
PK iduuid
FK recipe_id→ recipe
FK ingredient_id→ ingredient
quantitynumeric(8,2)
unitvarchar(20)
sort_ordersmallint
recipe_step
PK iduuid
FK recipe_id→ recipe
step_numbersmallint
instructiontext
tag NEW
PK iduuid
FK household_id→ household
namecitext
tag_typevarchar(20)
recipe_tag CHANGED
FK recipe_id→ recipe
FK tag_id→ tag NEW
PK (recipe_id, tag_id)composite
week_plan
PK iduuid
FK household_id→ household
week_startdate (Monday)
statusenum(draft,confirmed)
confirmed_attimestamptz NULL
week_plan_slot
PK iduuid
FK week_plan_id→ week_plan
FK recipe_id→ recipe
slot_datedate
cooking_log
PK iduuid
FK recipe_id→ recipe
FK household_id→ household
cooked_ondate
FK cooked_by→ user_account
shopping_list
PK iduuid
FK household_id→ household
FK week_plan_id→ week_plan
statusenum(draft,published,done)
published_attimestamptz NULL
shopping_list_item
PK iduuid
FK shopping_list_id→ shopping_list
FK ingredient_id→ ingredient NULL
custom_namevarchar(200) NULL
quantity / unitnumeric / varchar
is_checkedboolean
source_recipesuuid[]
pantry_item
PK iduuid
FK household_id→ household
FK ingredient_id→ ingredient NULL
custom_namevarchar(200) NULL
quantity / unitnumeric / varchar
best_beforedate NULL
opened_ondate NULL
admin_audit_log NEW
PK iduuid
FK admin_id→ user_account
FK target_user_id→ user_account
actionvarchar(30)
detailjsonb NULL
performed_attimestamptz
Tag model — M:N via reference table
v1.0 stored tags as raw strings in recipe_tag. v1.1 fixes this to a proper many-to-many relationship. One recipe can have many tags. One tag can appear on many recipes. Tags are owned by a household and typed (protein, dietary, cuisine) to enable structured filtering.

What changed and why

Before (v1.0): recipe_tag(recipe_id, tag varchar(50)) — tag name stored as raw string per row. 30 recipes tagged "chicken" = 30 copies of the string "chicken". Renaming requires updating every row. No canonical list of available tags. No typed categorization.

After (v1.1): tag(id, household_id, name, tag_type) + recipe_tag(recipe_id, tag_id) — pure M:N junction. Rename a tag in one UPDATE. List available tags with a simple SELECT. Filter by tag_type (protein, dietary, cuisine) for the J2 suggestion engine. The junction PK is composite (recipe_id, tag_id) — no surrogate key needed.

tag NEW in v1.1

Recipe
Reference table for category tags. Scoped per household — each household can have its own tag vocabulary. tag_type classifies tags for the suggestion engine: "protein" tags trigger consecutive-day avoidance, "dietary" tags are informational.
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
household_iduuidNOT NULL, FK → household ON DELETE CASCADETags belong to a household
namecitextNOT NULL"Chicken", "Fish", "Vegetarian", "Pasta"
tag_typevarchar(20)NOT NULL, CHECK(tag_type IN ('protein','dietary','cuisine','other'))Classification. "protein" powers J2 consecutive-day filter.
created_attimestamptzNOT NULL, DEFAULT now()Creation time

recipe_tag CHANGED in v1.1

Recipe
Pure M:N junction table. No surrogate key — the composite PK (recipe_id, tag_id) is the natural key. A recipe can have many tags; a tag can appear on many recipes. Both directions are indexed.
ColumnTypeConstraintsPurpose
recipe_iduuidNOT NULL, FK → recipe ON DELETE CASCADE, part of composite PKWhich recipe
tag_iduuidNOT NULL, FK → tag ON DELETE CASCADE, part of composite PKWhich tag

J2 — Same-protein consecutive day check (updated for M:N)

Uses tag.tag_type = 'protein' to filter only protein tags from the M:N join
-- What protein tags are on adjacent planned days?
SELECT wps.slot_date, t.name AS protein
FROM week_plan_slot wps
JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
JOIN tag t ON t.id = rt.tag_id
WHERE wps.week_plan_id = $1
  AND t.tag_type = 'protein'
ORDER BY wps.slot_date;
Ingredient category — 1:N reference table
v1.0 stored category as a raw string on ingredient. v1.1 normalizes this to a proper 1:N relationship. One ingredient belongs to one category. One category contains many ingredients. Categories are owned per household and ordered for shopping list grouping.

What changed and why

Before: ingredient.category varchar(30) — raw string. 15 ingredients labelled "Produce" = 15 copies. Rename requires updating every row. No canonical list. No display ordering for the aisle-grouped shopping list.

After: ingredient_category(id, household_id, name, sort_order) + ingredient.category_id FK. Rename once, applies everywhere. sort_order controls the display order on the aisle-grouped shopping list (J5 V2). Category is nullable on ingredient — uncategorized ingredients fall into an "Other" group in the UI.

ingredient_category NEW in v1.1

Recipe
Reference table for ingredient aisle categories. Scoped per household — each household can customize their store layout. sort_order controls the display sequence in the aisle-grouped shopping list view (J5 screen D1 variant V2).
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
household_iduuidNOT NULL, FK → household ON DELETE CASCADECategories are per-household
namecitextNOT NULL"Produce", "Fish & Meat", "Dry Goods", "Dairy", "Sauces & Condiments"
sort_ordersmallintNOT NULL, DEFAULT 0Display order — matches supermarket aisle flow
created_attimestamptzNOT NULL, DEFAULT now()Creation time
Admin user management — new in v1.1
The system needs a platform-level admin who can create user accounts, update them, and reset passwords. This is separate from the household "planner" role — a planner manages meal plans, an admin manages the platform. The two role systems are orthogonal: system_role (admin vs user) lives on user_account; household_role (planner vs member) lives on household_member.

Two role systems — don't confuse them

system_role on user_account: platform-level. "admin" can manage all user accounts. "user" is a normal user. This is about platform administration.

household role on household_member: app-level. "planner" has full access to 18 screens. "member" sees C1 read-only + D1 collaborative. This is about what you can do within a household.

An admin can also be a planner in their own household. The roles are independent.

user_account CHANGED in v1.1

Auth
User identity for both app users and platform admins. system_role determines platform-level access. is_active allows admins to deactivate accounts without deleting them. Authentication handled here; household authorization in household_member.
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
emailcitextNOT NULL, UNIQUELogin identifier, case-insensitive
display_namevarchar(100)NOT NULLShown in UI (sidebar avatar initials)
password_hashvarchar(255)NOT NULLbcrypt/argon2 hash — never exposed via API
system_rolevarchar(10)NOT NULL, DEFAULT 'user', CHECK(system_role IN ('admin','user'))NEW — platform role. Admin can manage all accounts.
is_activebooleanNOT NULL, DEFAULT trueNEW — admin can deactivate accounts. Inactive users cannot log in.
created_attimestamptzNOT NULL, DEFAULT now()Account creation time
updated_attimestamptzNOT NULL, DEFAULT now()Last profile edit

admin_audit_log NEW in v1.1

Admin
Immutable audit trail for all admin actions on user accounts. Every account creation, update, or password reset by an admin is logged here. Never updated or deleted. Used for compliance, debugging, and accountability.
ColumnTypeConstraintsPurpose
iduuidPK, gen_random_uuid()Surrogate PK
admin_iduuidNOT NULL, FK → user_account ON DELETE RESTRICTWhich admin performed the action
target_user_iduuidNOT NULL, FK → user_account ON DELETE RESTRICTWhich user was affected
actionvarchar(30)NOT NULL, CHECK(action IN ('create_account','update_account','reset_password','deactivate_account','reactivate_account','change_system_role'))What happened
detailjsonbNULLChanged fields snapshot: {"field":"email","old":"a@x.com","new":"b@x.com"}
ip_addressinetNULLAdmin's IP for security audit
performed_attimestamptzNOT NULL, DEFAULT now()When the action occurred
Foreign key map (updated v1.1)
From tableColumnReferencesCardinalityOn delete
householdcreated_byuser_account.idN:1RESTRICT
household_memberhousehold_idhousehold.idN:1CASCADE
household_memberuser_iduser_account.idN:1CASCADE
household_invitehousehold_idhousehold.idN:1CASCADE
recipehousehold_idhousehold.idN:1CASCADE
ingredienthousehold_idhousehold.idN:1CASCADE
ingredient_categoryhousehold_idhousehold.idN:1CASCADE
ingredientcategory_idingredient_category.idN:1 (nullable)SET NULL
taghousehold_idhousehold.idN:1CASCADE
recipe_ingredientrecipe_idrecipe.idN:1CASCADE
recipe_ingredientingredient_idingredient.idN:1RESTRICT
recipe_steprecipe_idrecipe.idN:1CASCADE
recipe_tagrecipe_idrecipe.idM:N junctionCASCADE
recipe_tagtag_idtag.idM:N junctionCASCADE
week_planhousehold_idhousehold.idN:1CASCADE
week_plan_slotweek_plan_idweek_plan.idN:1CASCADE
week_plan_slotrecipe_idrecipe.idN:1RESTRICT
cooking_logrecipe_idrecipe.idN:1RESTRICT
cooking_logweek_plan_slot_idweek_plan_slot.idN:1 (nullable)SET NULL
shopping_listweek_plan_idweek_plan.idN:1RESTRICT
shopping_list_itemshopping_list_idshopping_list.idN:1CASCADE
shopping_list_itemingredient_idingredient.idN:1 (nullable)SET NULL
pantry_itemhousehold_idhousehold.idN:1CASCADE
pantry_itemingredient_idingredient.idN:1 (nullable)SET NULL
admin_audit_logadmin_iduser_account.idN:1RESTRICT
admin_audit_logtarget_user_iduser_account.idN:1RESTRICT
= new or changed in v1.1
Key query patterns

J2 — Ingredient repetition check (last 3 days)

Frequency: ~10×/week · Target: <50ms
WITH recent_meals AS (
  SELECT recipe_id, cooked_on
  FROM cooking_log
  WHERE household_id = $1
    AND cooked_on >= CURRENT_DATE - INTERVAL '3 days'
)
SELECT DISTINCT i.id, i.name
FROM recent_meals rm
JOIN recipe_ingredient ri ON ri.recipe_id = rm.recipe_id
JOIN ingredient i ON i.id = ri.ingredient_id;

J2 — Protein tags on adjacent days (M:N join)

Frequency: ~10×/week · Target: <30ms
SELECT wps.slot_date, t.name AS protein
FROM week_plan_slot wps
JOIN recipe_tag rt ON rt.recipe_id = wps.recipe_id
JOIN tag t ON t.id = rt.tag_id
WHERE wps.week_plan_id = $1
  AND t.tag_type = 'protein'
ORDER BY wps.slot_date;

J5 — Shopping list generation (merged + staples filtered)

Frequency: 1×/week · Target: <200ms
SELECT i.id, i.name,
       SUM(ri.quantity) AS total_qty, ri.unit,
       ARRAY_AGG(DISTINCT r.id) AS source_recipe_ids
FROM week_plan_slot wps
JOIN recipe r ON r.id = wps.recipe_id
JOIN recipe_ingredient ri ON ri.recipe_id = r.id
JOIN ingredient i ON i.id = ri.ingredient_id
WHERE wps.week_plan_id = $1
  AND i.is_staple = false
GROUP BY i.id, i.name, ri.unit
ORDER BY i.name;

Pantry — Items expiring within 3 days

Frequency: daily · Target: <20ms
SELECT pi.id, COALESCE(i.name, pi.custom_name) AS name,
       pi.best_before, pi.quantity, pi.unit
FROM pantry_item pi
LEFT JOIN ingredient i ON i.id = pi.ingredient_id
WHERE pi.household_id = $1
  AND pi.best_before IS NOT NULL
  AND pi.best_before <= CURRENT_DATE + INTERVAL '3 days'
ORDER BY pi.best_before;

Admin — All actions on a user (audit trail)

Frequency: on-demand · Target: <50ms
SELECT aal.action, aal.detail, aal.performed_at,
       admin.display_name AS admin_name, admin.email AS admin_email
FROM admin_audit_log aal
JOIN user_account admin ON admin.id = aal.admin_id
WHERE aal.target_user_id = $1
ORDER BY aal.performed_at DESC;

All tags for a recipe (M:N forward lookup)

Frequency: every recipe detail load · Target: <10ms
SELECT t.id, t.name, t.tag_type
FROM recipe_tag rt
JOIN tag t ON t.id = rt.tag_id
WHERE rt.recipe_id = $1
ORDER BY t.tag_type, t.name;

All recipes with a specific tag (M:N reverse lookup)

Frequency: J2 suggestion filter, B1 filter chips · Target: <30ms
SELECT r.id, r.name, r.effort, r.cook_time_min
FROM recipe_tag rt
JOIN recipe r ON r.id = rt.recipe_id
WHERE rt.tag_id = $1
  AND r.deleted_at IS NULL
ORDER BY r.name;
Migration order (v1.1)

Migration 001 — Extensions & triggers

Run once before any table creation
CREATE EXTENSION IF NOT EXISTS "pgcrypto";   -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "citext";     -- case-insensitive text

CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
  NEW.updated_at = NOW();
  RETURN NEW;
END;
$$ LANGUAGE plpgsql;

Table creation order (respects FK dependencies)

1. user_account → 2. household → 3. household_member → 4. household_invite → 5. ingredient_category → 6. ingredient → 7. tag → 8. recipe → 9. recipe_ingredient → 10. recipe_step → 11. recipe_tag → 12. week_plan → 13. week_plan_slot → 14. cooking_log → 15. shopping_list → 16. shopping_list_item → 17. pantry_item → 18. admin_audit_log

Immutability rules for audit tables

Apply after admin_audit_log creation
-- Prevent accidental updates/deletes on audit log
CREATE RULE no_update_audit AS ON UPDATE TO admin_audit_log
  DO INSTEAD NOTHING;
CREATE RULE no_delete_audit AS ON DELETE TO admin_audit_log
  DO INSTEAD NOTHING;
Journey → table coverage matrix (v1.1)
JourneyReadsWritesCritical path
J1 · Add recipeingredient, tag (autocomplete)recipe, recipe_ingredient, recipe_step, recipe_tag, ingredient, tagRecipe INSERT + child rows + tag associations in one transaction
J2 · Plan weekrecipe, recipe_ingredient, recipe_tag, tag, cooking_log, ingredientweek_plan, week_plan_slotVariety CTE joins tag (type=protein) for consecutive-day check
J3 · Cook tonightweek_plan_slot, recipe, recipe_ingredient, recipe_stepcooking_logcooking_log INSERT (immutable event)
J4 · Adapt on flyrecipe, recipe_tag, tag, cooking_logweek_plan_slot (UPDATE recipe_id)Slot UPDATE + variety recompute ≤ 3 taps
J5 · Shopping listweek_plan_slot, recipe_ingredient, ingredient, ingredient_categoryshopping_list, shopping_list_itemMerge query (GROUP BY ingredient, SUM quantity) + aisle grouping via category
J6 · Household setupuser_account, household, household_member, household_invite, ingredient (staples), tag (seed data), ingredient_category (seed data)Household creation + seed data in one transaction
Pantrypantry_item, ingredientpantry_itemExpiry notification query (daily)
Adminuser_account, admin_audit_loguser_account, admin_audit_logEvery admin action → audit log INSERT in same transaction
Pushback & trade-off log

v1.0 bug: recipe_tag was 1:N, not M:N

Fixed in v1.1. v1.0 stored tags as raw strings, making recipe_tag structurally a 1:N (one recipe → many string rows) rather than a true M:N. The tag string had no identity — "chicken" on recipe A and "chicken" on recipe B were unrelated rows. This prevented tag renaming, tag listing, and structured filtering. v1.1 adds a tag reference table, making recipe_tag a proper M:N junction with FK integrity in both directions.

v1.0 bug: ingredient.category was a raw string, not a FK

Fixed in v1.1. Same anti-pattern as the tag issue. 15 ingredients all storing the string "Produce" independently — no shared identity, no rename capability, no canonical list, no sort order. v1.1 extracts this to ingredient_category as a reference table with a 1:N FK from ingredient. The sort_order column enables the aisle-grouped shopping list (J5 D1 V2) to match supermarket layout. Category is nullable — uncategorized ingredients group into "Other."

source_recipes as uuid[] — trade-off accepted

Violates 1NF. But it's write-once display metadata, never joined against. A junction table would add ~60 rows/week for zero query benefit. Sunset plan: migrate to junction table if future requirements need "which list items came from recipe X?"

Variety score is computed, not materialized

At ~50 recipes × 7 slots, the CTE runs in <100ms. Materialized view adds staleness risk. At 100× scale, we add materialized view with refresh-on-mutation triggers.

admin_audit_log.detail uses JSONB — justified

This is the right use case for schemaless data. Each action type has a different shape (password reset has a "reason", email change has "old"+"new", creation has full profile). The data is write-once, append-only, and queried for display only. Normalizing it into typed columns would require a different schema per action type for no benefit.

Rejected: separate admin_user table

Having a separate table for admins would split identity across two tables. Login would need to check both. An admin who is also a household planner would need two accounts. Instead, system_role on user_account keeps one identity per person. The role check is a single column read.