-- ============================================================================ -- 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