grocery-app/packages/db/migrations/20260526_010000_custom_store_locations.sql

466 lines
14 KiB
PL/PgSQL

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;