costco-grocery-list/packages/db/migrations/multi_household_architecture.sql

398 lines
14 KiB
PL/PgSQL

-- ============================================================================
-- Multi-Household & Multi-Store Architecture Migration
-- ============================================================================
-- This migration transforms the single-list app into a multi-tenant system
-- supporting multiple households, each with multiple stores.
--
-- IMPORTANT: Backup your database before running this migration!
-- pg_dump grocery_list > backup_$(date +%Y%m%d).sql
--
-- Migration Strategy:
-- 1. Create new tables
-- 2. Create "Main Household" for existing users
-- 3. Migrate existing data to new structure
-- 4. Update roles (keep users.role for system admin)
-- 5. Verify data integrity
-- 6. (Manual step) Drop old tables after verification
-- ============================================================================
BEGIN;
-- ============================================================================
-- STEP 1: CREATE NEW TABLES
-- ============================================================================
-- Households table
CREATE TABLE IF NOT EXISTS households (
id SERIAL PRIMARY KEY,
name VARCHAR(100) NOT NULL,
created_at TIMESTAMP DEFAULT NOW(),
created_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
invite_code VARCHAR(20) UNIQUE NOT NULL,
code_expires_at TIMESTAMP
);
CREATE INDEX idx_households_invite_code ON households(invite_code);
COMMENT ON TABLE households IS 'Household groups (families, roommates, etc.)';
COMMENT ON COLUMN households.invite_code IS 'Unique code for inviting users to join household';
-- Store types table
CREATE TABLE IF NOT EXISTS stores (
id SERIAL PRIMARY KEY,
name VARCHAR(50) NOT NULL UNIQUE,
default_zones JSONB,
created_at TIMESTAMP DEFAULT NOW()
);
COMMENT ON TABLE stores IS 'Store types/chains (Costco, Target, Walmart, etc.)';
COMMENT ON COLUMN stores.default_zones IS 'JSON array of default zone names for this store type';
-- User-Household membership with per-household roles
CREATE TABLE IF NOT EXISTS household_members (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
role VARCHAR(20) NOT NULL CHECK (role IN ('admin', 'user')),
joined_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, user_id)
);
CREATE INDEX idx_household_members_user ON household_members(user_id);
CREATE INDEX idx_household_members_household ON household_members(household_id);
COMMENT ON TABLE household_members IS 'User membership in households with per-household roles';
COMMENT ON COLUMN household_members.role IS 'admin: full control, user: standard member';
-- Household-Store relationship
CREATE TABLE IF NOT EXISTS household_stores (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
is_default BOOLEAN DEFAULT FALSE,
added_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id)
);
CREATE INDEX idx_household_stores_household ON household_stores(household_id);
COMMENT ON TABLE household_stores IS 'Which stores each household shops at';
-- Master item catalog (shared across all households)
CREATE TABLE IF NOT EXISTS items (
id SERIAL PRIMARY KEY,
name VARCHAR(255) NOT NULL UNIQUE,
default_image BYTEA,
default_image_mime_type VARCHAR(50),
created_at TIMESTAMP DEFAULT NOW(),
usage_count INTEGER DEFAULT 0
);
CREATE INDEX idx_items_name ON items(name);
CREATE INDEX idx_items_usage_count ON items(usage_count DESC);
COMMENT ON TABLE items IS 'Master item catalog shared across all households';
COMMENT ON COLUMN items.usage_count IS 'Popularity metric for suggestions';
-- Household-specific grocery lists (per store)
CREATE TABLE IF NOT EXISTS household_lists (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL DEFAULT 1,
bought BOOLEAN DEFAULT FALSE,
custom_image BYTEA,
custom_image_mime_type VARCHAR(50),
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
modified_on TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id)
);
CREATE INDEX idx_household_lists_household_store ON household_lists(household_id, store_id);
CREATE INDEX idx_household_lists_bought ON household_lists(household_id, store_id, bought);
CREATE INDEX idx_household_lists_modified ON household_lists(modified_on DESC);
COMMENT ON TABLE household_lists IS 'Grocery lists scoped to household + store combination';
-- Household-specific item classifications (per store)
CREATE TABLE IF NOT EXISTS household_item_classifications (
id SERIAL PRIMARY KEY,
household_id INTEGER REFERENCES households(id) ON DELETE CASCADE,
store_id INTEGER REFERENCES stores(id) ON DELETE CASCADE,
item_id INTEGER REFERENCES items(id) ON DELETE CASCADE,
item_type VARCHAR(50),
item_group VARCHAR(100),
zone VARCHAR(100),
confidence DECIMAL(3,2) DEFAULT 1.0 CHECK (confidence >= 0 AND confidence <= 1),
source VARCHAR(20) DEFAULT 'user' CHECK (source IN ('user', 'ml', 'default')),
created_at TIMESTAMP DEFAULT NOW(),
updated_at TIMESTAMP DEFAULT NOW(),
UNIQUE(household_id, store_id, item_id)
);
CREATE INDEX idx_household_classifications ON household_item_classifications(household_id, store_id);
CREATE INDEX idx_household_classifications_type ON household_item_classifications(item_type);
CREATE INDEX idx_household_classifications_zone ON household_item_classifications(zone);
COMMENT ON TABLE household_item_classifications IS 'Item classifications scoped to household + store';
-- History tracking
CREATE TABLE IF NOT EXISTS household_list_history (
id SERIAL PRIMARY KEY,
household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE,
quantity INTEGER NOT NULL,
added_by INTEGER REFERENCES users(id) ON DELETE SET NULL,
added_on TIMESTAMP DEFAULT NOW()
);
CREATE INDEX idx_household_history_list ON household_list_history(household_list_id);
CREATE INDEX idx_household_history_user ON household_list_history(added_by);
CREATE INDEX idx_household_history_date ON household_list_history(added_on DESC);
COMMENT ON TABLE household_list_history IS 'Tracks who added items and when';
-- ============================================================================
-- STEP 2: CREATE DEFAULT HOUSEHOLD AND STORE
-- ============================================================================
-- Create default household for existing users
INSERT INTO households (name, created_by, invite_code)
SELECT
'Main Household',
(SELECT id FROM users WHERE role = 'admin' LIMIT 1), -- First admin as creator
'MAIN' || LPAD(FLOOR(RANDOM() * 1000000)::TEXT, 6, '0') -- Random 6-digit code
WHERE NOT EXISTS (SELECT 1 FROM households WHERE name = 'Main Household');
-- Create default Costco store
INSERT INTO stores (name, default_zones)
VALUES (
'Costco',
'{
"zones": [
"Entrance & Seasonal",
"Fresh Produce",
"Meat & Seafood",
"Dairy & Refrigerated",
"Deli & Prepared Foods",
"Bakery & Bread",
"Frozen Foods",
"Beverages",
"Snacks & Candy",
"Pantry & Dry Goods",
"Health & Beauty",
"Household & Cleaning",
"Other"
]
}'::jsonb
)
ON CONFLICT (name) DO NOTHING;
-- Link default household to default store
INSERT INTO household_stores (household_id, store_id, is_default)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
(SELECT id FROM stores WHERE name = 'Costco'),
TRUE
WHERE NOT EXISTS (
SELECT 1 FROM household_stores
WHERE household_id = (SELECT id FROM households WHERE name = 'Main Household')
);
-- ============================================================================
-- STEP 3: MIGRATE USERS TO HOUSEHOLD MEMBERS
-- ============================================================================
-- Add all existing users to Main Household
-- Old admins become household admins, others become standard users
INSERT INTO household_members (household_id, user_id, role)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
id,
CASE
WHEN role = 'admin' THEN 'admin'
ELSE 'user'
END
FROM users
WHERE NOT EXISTS (
SELECT 1 FROM household_members hm
WHERE hm.user_id = users.id
AND hm.household_id = (SELECT id FROM households WHERE name = 'Main Household')
);
-- ============================================================================
-- STEP 4: MIGRATE ITEMS TO MASTER CATALOG
-- ============================================================================
-- Extract unique items from grocery_list into master items table
INSERT INTO items (name, default_image, default_image_mime_type, created_at, usage_count)
SELECT
LOWER(TRIM(item_name)) as name,
item_image,
image_mime_type,
MIN(modified_on) as created_at,
COUNT(*) as usage_count
FROM grocery_list
WHERE NOT EXISTS (
SELECT 1 FROM items WHERE LOWER(items.name) = LOWER(TRIM(grocery_list.item_name))
)
GROUP BY LOWER(TRIM(item_name)), item_image, image_mime_type
ON CONFLICT (name) DO NOTHING;
-- ============================================================================
-- STEP 5: MIGRATE GROCERY_LIST TO HOUSEHOLD_LISTS
-- ============================================================================
-- Migrate current list to household_lists
INSERT INTO household_lists (
household_id,
store_id,
item_id,
quantity,
bought,
custom_image,
custom_image_mime_type,
added_by,
modified_on
)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
(SELECT id FROM stores WHERE name = 'Costco'),
i.id,
gl.quantity,
gl.bought,
CASE WHEN gl.item_image != i.default_image THEN gl.item_image ELSE NULL END, -- Only store if different
CASE WHEN gl.item_image != i.default_image THEN gl.image_mime_type ELSE NULL END,
gl.added_by,
gl.modified_on
FROM grocery_list gl
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
WHERE NOT EXISTS (
SELECT 1 FROM household_lists hl
WHERE hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
AND hl.item_id = i.id
)
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
-- ============================================================================
-- STEP 6: MIGRATE ITEM_CLASSIFICATION TO HOUSEHOLD_ITEM_CLASSIFICATIONS
-- ============================================================================
-- Migrate classifications
INSERT INTO household_item_classifications (
household_id,
store_id,
item_id,
item_type,
item_group,
zone,
confidence,
source,
created_at,
updated_at
)
SELECT
(SELECT id FROM households WHERE name = 'Main Household'),
(SELECT id FROM stores WHERE name = 'Costco'),
i.id,
ic.item_type,
ic.item_group,
ic.zone,
ic.confidence,
ic.source,
ic.created_at,
ic.updated_at
FROM item_classification ic
JOIN grocery_list gl ON ic.id = gl.id
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
WHERE NOT EXISTS (
SELECT 1 FROM household_item_classifications hic
WHERE hic.household_id = (SELECT id FROM households WHERE name = 'Main Household')
AND hic.store_id = (SELECT id FROM stores WHERE name = 'Costco')
AND hic.item_id = i.id
)
ON CONFLICT (household_id, store_id, item_id) DO NOTHING;
-- ============================================================================
-- STEP 7: MIGRATE GROCERY_HISTORY TO HOUSEHOLD_LIST_HISTORY
-- ============================================================================
-- Migrate history records
INSERT INTO household_list_history (household_list_id, quantity, added_by, added_on)
SELECT
hl.id,
gh.quantity,
gh.added_by,
gh.added_on
FROM grocery_history gh
JOIN grocery_list gl ON gh.list_item_id = gl.id
JOIN items i ON LOWER(i.name) = LOWER(TRIM(gl.item_name))
JOIN household_lists hl ON hl.item_id = i.id
AND hl.household_id = (SELECT id FROM households WHERE name = 'Main Household')
AND hl.store_id = (SELECT id FROM stores WHERE name = 'Costco')
WHERE NOT EXISTS (
SELECT 1 FROM household_list_history hlh
WHERE hlh.household_list_id = hl.id
AND hlh.added_by = gh.added_by
AND hlh.added_on = gh.added_on
);
-- ============================================================================
-- STEP 8: UPDATE USER ROLES (SYSTEM-WIDE)
-- ============================================================================
-- Update system roles: admin → system_admin, others → user
UPDATE users
SET role = 'system_admin'
WHERE role = 'admin';
UPDATE users
SET role = 'user'
WHERE role IN ('editor', 'viewer');
-- ============================================================================
-- VERIFICATION QUERIES
-- ============================================================================
-- Run these to verify migration success:
-- Check household created
-- SELECT * FROM households;
-- Check all users added to household
-- SELECT u.username, u.role as system_role, hm.role as household_role
-- FROM users u
-- JOIN household_members hm ON u.id = hm.user_id
-- ORDER BY u.id;
-- Check items migrated
-- SELECT COUNT(*) as total_items FROM items;
-- SELECT COUNT(*) as original_items FROM (SELECT DISTINCT item_name FROM grocery_list) sub;
-- Check lists migrated
-- SELECT COUNT(*) as new_lists FROM household_lists;
-- SELECT COUNT(*) as old_lists FROM grocery_list;
-- Check classifications migrated
-- SELECT COUNT(*) as new_classifications FROM household_item_classifications;
-- SELECT COUNT(*) as old_classifications FROM item_classification;
-- Check history migrated
-- SELECT COUNT(*) as new_history FROM household_list_history;
-- SELECT COUNT(*) as old_history FROM grocery_history;
-- ============================================================================
-- MANUAL STEPS AFTER VERIFICATION
-- ============================================================================
-- After verifying data integrity, uncomment and run these to clean up:
-- DROP TABLE IF EXISTS grocery_history CASCADE;
-- DROP TABLE IF EXISTS item_classification CASCADE;
-- DROP TABLE IF EXISTS grocery_list CASCADE;
COMMIT;
-- ============================================================================
-- ROLLBACK (if something goes wrong)
-- ============================================================================
-- ROLLBACK;
-- Then restore from backup:
-- psql -U your_user -d grocery_list < backup_YYYYMMDD.sql