BEGIN; -- Household-owned store brands. The legacy public.stores table remains for -- historical data and system-admin compatibility, but the household shopping -- flow should use these records plus store_locations. CREATE TABLE IF NOT EXISTS household_custom_stores ( id SERIAL PRIMARY KEY, household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, name VARCHAR(120) NOT NULL, normalized_name VARCHAR(120) NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(household_id, normalized_name) ); CREATE INDEX IF NOT EXISTS idx_household_custom_stores_household ON household_custom_stores(household_id); CREATE TABLE IF NOT EXISTS store_locations ( id SERIAL PRIMARY KEY, household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, household_store_id INTEGER NOT NULL REFERENCES household_custom_stores(id) ON DELETE CASCADE, name VARCHAR(160) NOT NULL, normalized_name VARCHAR(160) NOT NULL, address TEXT, is_default BOOLEAN NOT NULL DEFAULT FALSE, map_data JSONB NOT NULL DEFAULT '{}'::jsonb, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(household_store_id, normalized_name) ); CREATE INDEX IF NOT EXISTS idx_store_locations_household ON store_locations(household_id); CREATE INDEX IF NOT EXISTS idx_store_locations_store ON store_locations(household_store_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_store_locations_default_per_household ON store_locations(household_id) WHERE is_default; CREATE TABLE IF NOT EXISTS store_location_zones ( id SERIAL PRIMARY KEY, household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE, name VARCHAR(120) NOT NULL, normalized_name VARCHAR(120) NOT NULL, sort_order INTEGER NOT NULL DEFAULT 0, color VARCHAR(32), map_metadata JSONB NOT NULL DEFAULT '{}'::jsonb, is_active BOOLEAN NOT NULL DEFAULT TRUE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), UNIQUE(store_location_id, normalized_name) ); CREATE INDEX IF NOT EXISTS idx_store_location_zones_location_order ON store_location_zones(store_location_id, is_active, sort_order, name); CREATE TABLE IF NOT EXISTS household_item_images ( id BIGSERIAL PRIMARY KEY, household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, store_location_id INTEGER REFERENCES store_locations(id) ON DELETE CASCADE, household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE CASCADE, household_list_id INTEGER REFERENCES household_lists(id) ON DELETE CASCADE, image_scope VARCHAR(20) NOT NULL CHECK (image_scope IN ('catalog', 'list')), image BYTEA NOT NULL, mime_type VARCHAR(50) NOT NULL, created_by INTEGER REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_household_item_images_item ON household_item_images(household_store_item_id, image_scope, created_at DESC); CREATE INDEX IF NOT EXISTS idx_household_item_images_location ON household_item_images(household_id, store_location_id, created_at DESC); CREATE TABLE IF NOT EXISTS household_item_events ( id BIGSERIAL PRIMARY KEY, household_id INTEGER NOT NULL REFERENCES households(id) ON DELETE CASCADE, store_location_id INTEGER NOT NULL REFERENCES store_locations(id) ON DELETE CASCADE, household_store_item_id INTEGER REFERENCES household_store_items(id) ON DELETE SET NULL, household_list_id INTEGER REFERENCES household_lists(id) ON DELETE SET NULL, actor_user_id INTEGER REFERENCES users(id) ON DELETE SET NULL, event_type VARCHAR(50) NOT NULL CHECK ( event_type IN ( 'ITEM_ADDED', 'ITEM_BOUGHT', 'ITEM_UNBOUGHT', 'ITEM_QUANTITY_CHANGED', 'ITEM_DELETED', 'ITEM_CLASSIFICATION_CHANGED', 'ITEM_ZONE_CHANGED' ) ), quantity_delta INTEGER, quantity_after INTEGER, metadata JSONB NOT NULL DEFAULT '{}'::jsonb, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); CREATE INDEX IF NOT EXISTS idx_household_item_events_location_time ON household_item_events(household_id, store_location_id, created_at DESC); CREATE INDEX IF NOT EXISTS idx_household_item_events_item_time ON household_item_events(household_store_item_id, created_at DESC); -- Allow new location-scoped rows to be independent of the legacy global -- stores/items catalog. ALTER TABLE household_store_items ALTER COLUMN store_id DROP NOT NULL; ALTER TABLE household_lists ADD COLUMN IF NOT EXISTS store_location_id INTEGER; ALTER TABLE household_store_items ADD COLUMN IF NOT EXISTS store_location_id INTEGER, ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL; ALTER TABLE household_item_classifications ADD COLUMN IF NOT EXISTS store_location_id INTEGER, ADD COLUMN IF NOT EXISTS zone_id INTEGER REFERENCES store_location_zones(id) ON DELETE SET NULL; ALTER TABLE household_list_history ADD COLUMN IF NOT EXISTS store_location_id INTEGER; ALTER TABLE household_lists ADD COLUMN IF NOT EXISTS image_id BIGINT REFERENCES household_item_images(id) ON DELETE SET NULL; -- One owner per household, with defensive cleanup for older data. WITH ranked_owners AS ( SELECT id, ROW_NUMBER() OVER ( PARTITION BY household_id ORDER BY joined_at ASC, id ASC ) AS owner_rank FROM household_members WHERE role = 'owner' ) UPDATE household_members hm SET role = 'admin' FROM ranked_owners ro WHERE hm.id = ro.id AND ro.owner_rank > 1; CREATE UNIQUE INDEX IF NOT EXISTS uq_household_members_one_owner ON household_members(household_id) WHERE role = 'owner'; -- Backfill custom stores from current household/global-store links. INSERT INTO household_custom_stores ( household_id, name, normalized_name, created_at, updated_at ) SELECT hs.household_id, s.name, LOWER(TRIM(s.name)), COALESCE(MIN(hs.added_at), NOW()), NOW() FROM household_stores hs JOIN stores s ON s.id = hs.store_id GROUP BY hs.household_id, s.name ON CONFLICT (household_id, normalized_name) DO NOTHING; WITH ranked_links AS ( SELECT hs.household_id, hcs.id AS household_store_id, ROW_NUMBER() OVER ( PARTITION BY hs.household_id ORDER BY hs.is_default DESC, hs.added_at ASC, hs.id ASC ) AS household_rank FROM household_stores hs JOIN stores s ON s.id = hs.store_id JOIN household_custom_stores hcs ON hcs.household_id = hs.household_id AND hcs.normalized_name = LOWER(TRIM(s.name)) ) INSERT INTO store_locations ( household_id, household_store_id, name, normalized_name, is_default, created_at, updated_at ) SELECT household_id, household_store_id, 'Default Location', 'default location', household_rank = 1, NOW(), NOW() FROM ranked_links ON CONFLICT (household_store_id, normalized_name) DO NOTHING; -- Backfill location ids onto existing records. UPDATE household_store_items hsi SET store_location_id = sl.id FROM stores s JOIN household_custom_stores hcs ON hcs.normalized_name = LOWER(TRIM(s.name)) JOIN store_locations sl ON sl.household_store_id = hcs.id AND sl.normalized_name = 'default location' WHERE hsi.store_id = s.id AND hcs.household_id = hsi.household_id AND hsi.store_location_id IS NULL; UPDATE household_lists hl SET store_location_id = hsi.store_location_id FROM household_store_items hsi WHERE hl.household_store_item_id = hsi.id AND hl.store_location_id IS NULL; UPDATE household_item_classifications hic SET store_location_id = hsi.store_location_id FROM household_store_items hsi WHERE hic.household_store_item_id = hsi.id AND hic.store_location_id IS NULL; UPDATE household_list_history hlh SET store_location_id = hl.store_location_id FROM household_lists hl WHERE hlh.household_list_id = hl.id AND hlh.store_location_id IS NULL; DO $$ BEGIN IF EXISTS (SELECT 1 FROM household_store_items WHERE store_location_id IS NULL) THEN RAISE EXCEPTION 'Failed to backfill household_store_items.store_location_id'; END IF; IF EXISTS (SELECT 1 FROM household_lists WHERE store_location_id IS NULL) THEN RAISE EXCEPTION 'Failed to backfill household_lists.store_location_id'; END IF; IF EXISTS (SELECT 1 FROM household_item_classifications WHERE store_location_id IS NULL) THEN RAISE EXCEPTION 'Failed to backfill household_item_classifications.store_location_id'; END IF; END $$; ALTER TABLE household_store_items ALTER COLUMN store_location_id SET NOT NULL, ADD CONSTRAINT household_store_items_store_location_id_fkey FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; ALTER TABLE household_lists ALTER COLUMN store_location_id SET NOT NULL, ADD CONSTRAINT household_lists_store_location_id_fkey FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; ALTER TABLE household_item_classifications ALTER COLUMN store_location_id SET NOT NULL, ADD CONSTRAINT household_item_classifications_store_location_id_fkey FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; ALTER TABLE household_list_history ADD CONSTRAINT household_list_history_store_location_id_fkey FOREIGN KEY (store_location_id) REFERENCES store_locations(id) ON DELETE CASCADE; -- Seed v1 zones for every migrated/default location. WITH default_zones(name, sort_order, color) AS ( VALUES ('Entrance & Seasonal', 10, '#64748b'), ('Produce & Fresh Vegetables', 20, '#16a34a'), ('Meat & Seafood Counter', 30, '#dc2626'), ('Deli & Prepared Foods', 40, '#ea580c'), ('Bakery', 50, '#ca8a04'), ('Dairy & Refrigerated', 60, '#0284c7'), ('Frozen Foods', 70, '#2563eb'), ('Center Aisles (Dry Goods)', 80, '#7c3aed'), ('Beverages & Water', 90, '#0891b2'), ('Snacks & Candy', 100, '#db2777'), ('Household & Cleaning', 110, '#475569'), ('Health & Beauty', 120, '#9333ea'), ('Checkout Area', 130, '#0f172a') ) INSERT INTO store_location_zones ( household_id, store_location_id, name, normalized_name, sort_order, color ) SELECT sl.household_id, sl.id, dz.name, LOWER(TRIM(dz.name)), dz.sort_order, dz.color FROM store_locations sl CROSS JOIN default_zones dz ON CONFLICT (store_location_id, normalized_name) DO NOTHING; WITH existing_zone_names AS ( SELECT DISTINCT hic.household_id, hic.store_location_id, TRIM(hic.zone) AS name FROM household_item_classifications hic WHERE hic.zone IS NOT NULL AND TRIM(hic.zone) <> '' ), ranked_existing_zones AS ( SELECT ezn.*, 1000 + ROW_NUMBER() OVER ( PARTITION BY ezn.store_location_id ORDER BY ezn.name ) AS sort_order FROM existing_zone_names ezn ) INSERT INTO store_location_zones ( household_id, store_location_id, name, normalized_name, sort_order ) SELECT household_id, store_location_id, name, LOWER(TRIM(name)), sort_order FROM ranked_existing_zones ON CONFLICT (store_location_id, normalized_name) DO NOTHING; UPDATE household_item_classifications hic SET zone_id = slz.id FROM store_location_zones slz WHERE slz.store_location_id = hic.store_location_id AND slz.normalized_name = LOWER(TRIM(hic.zone)) AND hic.zone_id IS NULL AND hic.zone IS NOT NULL AND TRIM(hic.zone) <> ''; -- Backfill image references while retaining old BYTEA columns for rollback and -- old-code compatibility. INSERT INTO household_item_images ( household_id, store_location_id, household_store_item_id, image_scope, image, mime_type, created_at ) SELECT hsi.household_id, hsi.store_location_id, hsi.id, 'catalog', hsi.custom_image, COALESCE(hsi.custom_image_mime_type, 'image/jpeg'), COALESCE(hsi.updated_at, NOW()) FROM household_store_items hsi WHERE hsi.custom_image IS NOT NULL AND hsi.image_id IS NULL; UPDATE household_store_items hsi SET image_id = img.id FROM household_item_images img WHERE img.household_store_item_id = hsi.id AND img.image_scope = 'catalog' AND hsi.image_id IS NULL; INSERT INTO household_item_images ( household_id, store_location_id, household_store_item_id, household_list_id, image_scope, image, mime_type, created_by, created_at ) SELECT hl.household_id, hl.store_location_id, hl.household_store_item_id, hl.id, 'list', hl.custom_image, COALESCE(hl.custom_image_mime_type, 'image/jpeg'), hl.added_by, COALESCE(hl.modified_on, NOW()) FROM household_lists hl WHERE hl.custom_image IS NOT NULL AND hl.image_id IS NULL; UPDATE household_lists hl SET image_id = img.id FROM household_item_images img WHERE img.household_list_id = hl.id AND img.image_scope = 'list' AND hl.image_id IS NULL; -- Backfill known add history into the new event log. INSERT INTO household_item_events ( household_id, store_location_id, household_store_item_id, household_list_id, actor_user_id, event_type, quantity_delta, metadata, created_at ) SELECT hl.household_id, hl.store_location_id, hlh.household_store_item_id, hlh.household_list_id, hlh.added_by, 'ITEM_ADDED', hlh.quantity, jsonb_build_object('source', 'household_list_history'), hlh.added_on FROM household_list_history hlh JOIN household_lists hl ON hl.id = hlh.household_list_id WHERE NOT EXISTS ( SELECT 1 FROM household_item_events hie WHERE hie.household_list_id = hlh.household_list_id AND hie.household_store_item_id = hlh.household_store_item_id AND hie.event_type = 'ITEM_ADDED' AND hie.created_at = hlh.added_on ); CREATE UNIQUE INDEX IF NOT EXISTS uq_household_store_items_location_name ON household_store_items(household_id, store_location_id, normalized_name); CREATE UNIQUE INDEX IF NOT EXISTS uq_household_lists_location_item ON household_lists(household_id, store_location_id, household_store_item_id); CREATE UNIQUE INDEX IF NOT EXISTS uq_household_item_classifications_location_item ON household_item_classifications(household_id, store_location_id, household_store_item_id); CREATE INDEX IF NOT EXISTS idx_household_lists_location_bought ON household_lists(household_id, store_location_id, bought); CREATE INDEX IF NOT EXISTS idx_household_classifications_location_zone ON household_item_classifications(household_id, store_location_id, zone_id); CREATE INDEX IF NOT EXISTS idx_household_list_history_location_item ON household_list_history(store_location_id, household_store_item_id, added_on DESC); COMMIT;