Add Flyway migrations V001-V019 for all 18 tables

V001: pgcrypto + citext extensions, trigger_set_updated_at function.
V002-V019: tables in FK dependency order per data model v1.1.

Spec fixes incorporated:
- recipe: added created_at/updated_at (spec says all mutable tables
  carry audit timestamps, but ERD omitted them)
- shopping_list: added household_id FK for HouseholdContext scoping
- shopping_list_item: added checked_by FK (API returns checkedBy)
- cooking_log: omitted phantom week_plan_slot_id (in FK map but
  absent from ERD, API, and all journeys)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-04-01 20:56:25 +02:00
parent 247a130b69
commit 10b4d567d3
19 changed files with 226 additions and 0 deletions

View File

@@ -0,0 +1,12 @@
-- Extensions
CREATE EXTENSION IF NOT EXISTS "pgcrypto"; -- gen_random_uuid()
CREATE EXTENSION IF NOT EXISTS "citext"; -- case-insensitive text
-- Trigger function: auto-update updated_at on row change
CREATE OR REPLACE FUNCTION trigger_set_updated_at()
RETURNS TRIGGER AS $$
BEGIN
NEW.updated_at = NOW();
RETURN NEW;
END;
$$ LANGUAGE plpgsql;

View File

@@ -0,0 +1,18 @@
CREATE TABLE user_account (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
email citext NOT NULL UNIQUE,
display_name varchar(100) NOT NULL,
password_hash varchar(255) NOT NULL,
system_role varchar(10) NOT NULL DEFAULT 'user'
CHECK (system_role IN ('admin', 'user')),
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_user_active ON user_account (is_active) WHERE is_active = true;
CREATE INDEX idx_user_system_role ON user_account (system_role) WHERE system_role = 'admin';
CREATE TRIGGER set_user_account_updated_at
BEFORE UPDATE ON user_account
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();

View File

@@ -0,0 +1,6 @@
CREATE TABLE household (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
name varchar(100) NOT NULL,
created_by uuid NOT NULL REFERENCES user_account (id) ON DELETE RESTRICT,
created_at timestamptz NOT NULL DEFAULT now()
);

View File

@@ -0,0 +1,10 @@
CREATE TABLE household_member (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
user_id uuid NOT NULL REFERENCES user_account (id) ON DELETE CASCADE,
role varchar(10) NOT NULL CHECK (role IN ('planner', 'member')),
joined_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_household_member_user UNIQUE (user_id)
);
CREATE INDEX idx_hm_household ON household_member (household_id);

View File

@@ -0,0 +1,8 @@
CREATE TABLE household_invite (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
invite_code varchar(20) NOT NULL UNIQUE,
status varchar(10) NOT NULL DEFAULT 'pending'
CHECK (status IN ('pending', 'used', 'expired')),
expires_at timestamptz NOT NULL
);

View File

@@ -0,0 +1,10 @@
CREATE TABLE ingredient_category (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
name citext NOT NULL,
sort_order smallint NOT NULL DEFAULT 0,
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_ingredient_category_name UNIQUE (household_id, name)
);
CREATE INDEX idx_ic_sort ON ingredient_category (household_id, sort_order);

View File

@@ -0,0 +1,10 @@
CREATE TABLE ingredient (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
name citext NOT NULL,
is_staple boolean NOT NULL DEFAULT false,
category_id uuid REFERENCES ingredient_category (id) ON DELETE SET NULL
);
CREATE INDEX idx_ingredient_household ON ingredient (household_id);
CREATE INDEX idx_ingredient_name ON ingredient (household_id, name);

View File

@@ -0,0 +1,11 @@
CREATE TABLE tag (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
name citext NOT NULL,
tag_type varchar(20) NOT NULL
CHECK (tag_type IN ('protein', 'dietary', 'cuisine', 'other')),
created_at timestamptz NOT NULL DEFAULT now(),
CONSTRAINT uq_tag_name UNIQUE (household_id, name)
);
CREATE INDEX idx_tag_type ON tag (household_id, tag_type);

View File

@@ -0,0 +1,19 @@
CREATE TABLE recipe (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
name varchar(200) NOT NULL,
serves smallint NOT NULL CHECK (serves BETWEEN 1 AND 20),
cook_time_min smallint NOT NULL CHECK (cook_time_min >= 0),
effort varchar(10) NOT NULL CHECK (effort IN ('easy', 'medium', 'hard')),
is_child_friendly boolean NOT NULL DEFAULT false,
hero_image_url varchar(500),
deleted_at timestamptz,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_recipe_household ON recipe (household_id) WHERE deleted_at IS NULL;
CREATE TRIGGER set_recipe_updated_at
BEFORE UPDATE ON recipe
FOR EACH ROW EXECUTE FUNCTION trigger_set_updated_at();

View File

@@ -0,0 +1,11 @@
CREATE TABLE recipe_ingredient (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id uuid NOT NULL REFERENCES recipe (id) ON DELETE CASCADE,
ingredient_id uuid NOT NULL REFERENCES ingredient (id) ON DELETE RESTRICT,
quantity numeric(8,2) NOT NULL,
unit varchar(20) NOT NULL,
sort_order smallint NOT NULL DEFAULT 0
);
CREATE INDEX idx_ri_recipe ON recipe_ingredient (recipe_id);
CREATE INDEX idx_ri_ingredient ON recipe_ingredient (ingredient_id);

View File

@@ -0,0 +1,8 @@
CREATE TABLE recipe_step (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id uuid NOT NULL REFERENCES recipe (id) ON DELETE CASCADE,
step_number smallint NOT NULL,
instruction text NOT NULL
);
CREATE INDEX idx_rs_recipe ON recipe_step (recipe_id, step_number);

View File

@@ -0,0 +1,7 @@
CREATE TABLE recipe_tag (
recipe_id uuid NOT NULL REFERENCES recipe (id) ON DELETE CASCADE,
tag_id uuid NOT NULL REFERENCES tag (id) ON DELETE CASCADE,
PRIMARY KEY (recipe_id, tag_id)
);
CREATE INDEX idx_rt_tag ON recipe_tag (tag_id);

View File

@@ -0,0 +1,9 @@
CREATE TABLE week_plan (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
week_start date NOT NULL,
status varchar(10) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'confirmed')),
confirmed_at timestamptz,
CONSTRAINT uq_week_plan_week UNIQUE (household_id, week_start)
);

View File

@@ -0,0 +1,8 @@
CREATE TABLE week_plan_slot (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
week_plan_id uuid NOT NULL REFERENCES week_plan (id) ON DELETE CASCADE,
recipe_id uuid NOT NULL REFERENCES recipe (id) ON DELETE RESTRICT,
slot_date date NOT NULL
);
CREATE INDEX idx_wps_plan ON week_plan_slot (week_plan_id, slot_date);

View File

@@ -0,0 +1,14 @@
-- Note: cooking_log.week_plan_slot_id from the FK map is intentionally
-- omitted — it appears in neither the ERD column list nor the API,
-- and no journey uses it. Can be added later if needed.
CREATE TABLE cooking_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
recipe_id uuid NOT NULL REFERENCES recipe (id) ON DELETE RESTRICT,
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
cooked_on date NOT NULL,
cooked_by uuid NOT NULL REFERENCES user_account (id) ON DELETE RESTRICT
);
CREATE INDEX idx_cl_household_date ON cooking_log (household_id, cooked_on DESC);
CREATE INDEX idx_cl_recipe ON cooking_log (recipe_id);

View File

@@ -0,0 +1,14 @@
-- Note: household_id added per review — spec only had week_plan_id,
-- but HouseholdContext pattern requires direct household scoping.
CREATE TABLE shopping_list (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
week_plan_id uuid NOT NULL REFERENCES week_plan (id) ON DELETE RESTRICT,
status varchar(10) NOT NULL DEFAULT 'draft'
CHECK (status IN ('draft', 'published', 'done')),
published_at timestamptz
);
CREATE INDEX idx_sl_household ON shopping_list (household_id);
CREATE INDEX idx_sl_week_plan ON shopping_list (week_plan_id);

View File

@@ -0,0 +1,16 @@
-- Note: checked_by added per review — API returns checkedBy on PATCH
-- but the original data model had no such column.
CREATE TABLE shopping_list_item (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
shopping_list_id uuid NOT NULL REFERENCES shopping_list (id) ON DELETE CASCADE,
ingredient_id uuid REFERENCES ingredient (id) ON DELETE SET NULL,
custom_name varchar(200),
quantity numeric(8,2),
unit varchar(20),
is_checked boolean NOT NULL DEFAULT false,
checked_by uuid REFERENCES user_account (id) ON DELETE SET NULL,
source_recipes uuid[]
);
CREATE INDEX idx_sli_list ON shopping_list_item (shopping_list_id);

View File

@@ -0,0 +1,14 @@
CREATE TABLE pantry_item (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
household_id uuid NOT NULL REFERENCES household (id) ON DELETE CASCADE,
ingredient_id uuid REFERENCES ingredient (id) ON DELETE SET NULL,
custom_name varchar(200),
quantity numeric(8,2),
unit varchar(20),
best_before date,
opened_on date
);
CREATE INDEX idx_pi_household ON pantry_item (household_id);
CREATE INDEX idx_pi_expiry ON pantry_item (household_id, best_before)
WHERE best_before IS NOT NULL;

View File

@@ -0,0 +1,21 @@
CREATE TABLE admin_audit_log (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
admin_id uuid NOT NULL REFERENCES user_account (id) ON DELETE RESTRICT,
target_user_id uuid NOT NULL REFERENCES user_account (id) ON DELETE RESTRICT,
action varchar(30) NOT NULL
CHECK (action IN (
'create_account', 'update_account', 'reset_password',
'deactivate_account', 'reactivate_account', 'change_system_role'
)),
detail jsonb,
ip_address inet,
performed_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_aal_target ON admin_audit_log (target_user_id, performed_at DESC);
CREATE INDEX idx_aal_admin ON admin_audit_log (admin_id, performed_at DESC);
CREATE INDEX idx_aal_action ON admin_audit_log (action, performed_at DESC);
-- Immutability: 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;