diff --git a/backend/src/main/resources/db/migration/V001__extensions_and_functions.sql b/backend/src/main/resources/db/migration/V001__extensions_and_functions.sql new file mode 100644 index 0000000..a19dc81 --- /dev/null +++ b/backend/src/main/resources/db/migration/V001__extensions_and_functions.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V002__create_user_account.sql b/backend/src/main/resources/db/migration/V002__create_user_account.sql new file mode 100644 index 0000000..491441a --- /dev/null +++ b/backend/src/main/resources/db/migration/V002__create_user_account.sql @@ -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(); diff --git a/backend/src/main/resources/db/migration/V003__create_household.sql b/backend/src/main/resources/db/migration/V003__create_household.sql new file mode 100644 index 0000000..30ff465 --- /dev/null +++ b/backend/src/main/resources/db/migration/V003__create_household.sql @@ -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() +); diff --git a/backend/src/main/resources/db/migration/V004__create_household_member.sql b/backend/src/main/resources/db/migration/V004__create_household_member.sql new file mode 100644 index 0000000..7d492db --- /dev/null +++ b/backend/src/main/resources/db/migration/V004__create_household_member.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V005__create_household_invite.sql b/backend/src/main/resources/db/migration/V005__create_household_invite.sql new file mode 100644 index 0000000..ebbbedb --- /dev/null +++ b/backend/src/main/resources/db/migration/V005__create_household_invite.sql @@ -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 +); diff --git a/backend/src/main/resources/db/migration/V006__create_ingredient_category.sql b/backend/src/main/resources/db/migration/V006__create_ingredient_category.sql new file mode 100644 index 0000000..b47a99b --- /dev/null +++ b/backend/src/main/resources/db/migration/V006__create_ingredient_category.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V007__create_ingredient.sql b/backend/src/main/resources/db/migration/V007__create_ingredient.sql new file mode 100644 index 0000000..ef9cb6c --- /dev/null +++ b/backend/src/main/resources/db/migration/V007__create_ingredient.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V008__create_tag.sql b/backend/src/main/resources/db/migration/V008__create_tag.sql new file mode 100644 index 0000000..fb39b25 --- /dev/null +++ b/backend/src/main/resources/db/migration/V008__create_tag.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V009__create_recipe.sql b/backend/src/main/resources/db/migration/V009__create_recipe.sql new file mode 100644 index 0000000..b67d114 --- /dev/null +++ b/backend/src/main/resources/db/migration/V009__create_recipe.sql @@ -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(); diff --git a/backend/src/main/resources/db/migration/V010__create_recipe_ingredient.sql b/backend/src/main/resources/db/migration/V010__create_recipe_ingredient.sql new file mode 100644 index 0000000..99f866c --- /dev/null +++ b/backend/src/main/resources/db/migration/V010__create_recipe_ingredient.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V011__create_recipe_step.sql b/backend/src/main/resources/db/migration/V011__create_recipe_step.sql new file mode 100644 index 0000000..158d571 --- /dev/null +++ b/backend/src/main/resources/db/migration/V011__create_recipe_step.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V012__create_recipe_tag.sql b/backend/src/main/resources/db/migration/V012__create_recipe_tag.sql new file mode 100644 index 0000000..e861134 --- /dev/null +++ b/backend/src/main/resources/db/migration/V012__create_recipe_tag.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V013__create_week_plan.sql b/backend/src/main/resources/db/migration/V013__create_week_plan.sql new file mode 100644 index 0000000..593a710 --- /dev/null +++ b/backend/src/main/resources/db/migration/V013__create_week_plan.sql @@ -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) +); diff --git a/backend/src/main/resources/db/migration/V014__create_week_plan_slot.sql b/backend/src/main/resources/db/migration/V014__create_week_plan_slot.sql new file mode 100644 index 0000000..55db876 --- /dev/null +++ b/backend/src/main/resources/db/migration/V014__create_week_plan_slot.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V015__create_cooking_log.sql b/backend/src/main/resources/db/migration/V015__create_cooking_log.sql new file mode 100644 index 0000000..5e72ce2 --- /dev/null +++ b/backend/src/main/resources/db/migration/V015__create_cooking_log.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V016__create_shopping_list.sql b/backend/src/main/resources/db/migration/V016__create_shopping_list.sql new file mode 100644 index 0000000..60c2d8a --- /dev/null +++ b/backend/src/main/resources/db/migration/V016__create_shopping_list.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V017__create_shopping_list_item.sql b/backend/src/main/resources/db/migration/V017__create_shopping_list_item.sql new file mode 100644 index 0000000..30c540b --- /dev/null +++ b/backend/src/main/resources/db/migration/V017__create_shopping_list_item.sql @@ -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); diff --git a/backend/src/main/resources/db/migration/V018__create_pantry_item.sql b/backend/src/main/resources/db/migration/V018__create_pantry_item.sql new file mode 100644 index 0000000..1152541 --- /dev/null +++ b/backend/src/main/resources/db/migration/V018__create_pantry_item.sql @@ -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; diff --git a/backend/src/main/resources/db/migration/V019__create_admin_audit_log.sql b/backend/src/main/resources/db/migration/V019__create_admin_audit_log.sql new file mode 100644 index 0000000..ad9fe65 --- /dev/null +++ b/backend/src/main/resources/db/migration/V019__create_admin_audit_log.sql @@ -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;