398 lines
14 KiB
PL/PgSQL
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
|