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