diff --git a/backend/migrations/0002_products.sql b/backend/migrations/0002_products.sql new file mode 100644 index 0000000..f7930dd --- /dev/null +++ b/backend/migrations/0002_products.sql @@ -0,0 +1,130 @@ +-- 0002_products.sql — SD-0002 §6.3 data model (SLICE-5). Forward-only (INV-7): +-- never edit once merged; add a new numbered migration. + +-- product — one catalog product per (storefront, handle) (INV-13/14). Option *names* +-- live here; product_type's kit values are schema-open but a service-layer rule +-- rejects non-'standalone' until #15 (same pattern as INV-4). +CREATE TABLE product ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + storefront_id BIGINT NOT NULL REFERENCES storefront (id), + handle TEXT NOT NULL, + title TEXT NOT NULL, + description_html TEXT, + vendor TEXT, + product_type TEXT NOT NULL DEFAULT 'standalone' + CHECK (product_type IN ('standalone', 'kit_virtual', 'kit_assembled')), + google_product_category TEXT, + tags TEXT[] NOT NULL DEFAULT '{}', + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('draft', 'active', 'archived')), + published BOOLEAN NOT NULL DEFAULT TRUE, + option1_name TEXT, + option2_name TEXT, + option3_name TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX product_handle_key ON product (storefront_id, handle); -- INV-13 + +-- variant — one purchasable form, identified by its option-value combo (INV-13). +-- SKU is indexed data, never identity. image_id FK is added after product_image. +CREATE TABLE variant ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + product_id BIGINT NOT NULL REFERENCES product (id), + position INTEGER NOT NULL, + option1_value TEXT, + option2_value TEXT, + option3_value TEXT, + sku TEXT, + barcode TEXT, + price NUMERIC, + cost NUMERIC, + weight NUMERIC, + weight_unit TEXT, + volume NUMERIC, + volume_unit TEXT, + tax_id_1 TEXT, + tax_id_2 TEXT, + inventory_tracker TEXT, + inventory_qty INTEGER, + image_id BIGINT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +-- Postgres 16 (compose + Cloud SQL pin): NULLS NOT DISTINCT makes the all-NULL +-- no-option combo unique too (INV-13). +CREATE UNIQUE INDEX variant_option_combo_key + ON variant (product_id, option1_value, option2_value, option3_value) + NULLS NOT DISTINCT; +CREATE INDEX variant_sku_idx ON variant (sku); + +-- product_image — identity within a product is source_url (§6.3); bytes live in +-- object storage from SLICE-7 (keys nullable until fetched). status starts 'pending'; +-- SLICE-5 stubs the fetch phase so rows simply stay pending. +CREATE TABLE product_image ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + product_id BIGINT NOT NULL REFERENCES product (id), + position INTEGER NOT NULL, + source_url TEXT NOT NULL, + alt_text TEXT, + status TEXT NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending', 'fetched', 'rejected_low_res', 'rejected_not_image', 'failed')), + failure_reason TEXT, + key_original TEXT, + key_thumb TEXT, + key_card TEXT, + key_detail TEXT, + import_run_id BIGINT, + fetched_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE UNIQUE INDEX product_image_src_key ON product_image (product_id, source_url); +ALTER TABLE variant + ADD CONSTRAINT variant_image_fk FOREIGN KEY (image_id) REFERENCES product_image (id); + +-- import_draft — the preview's server side (INV-11). file_bytes is the SLICE-5 +-- interim home for the upload (objectstore key from SLICE-7). Deleted outright on +-- cancel/expiry — drafts never appear in history (PUC-3a). +CREATE TABLE import_draft ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + storefront_id BIGINT NOT NULL REFERENCES storefront (id), + account_id BIGINT NOT NULL REFERENCES account (id), + file_name TEXT NOT NULL, + dialect TEXT NOT NULL, + file_bytes BYTEA NOT NULL, + summary JSONB NOT NULL, + records JSONB NOT NULL, + fingerprint TEXT NOT NULL, + unknown_columns TEXT[] NOT NULL DEFAULT '{}', + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- import_run — the durable record of one confirmed import (PUC-8); created only at +-- confirm (PUC-4). +CREATE TABLE import_run ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + storefront_id BIGINT NOT NULL REFERENCES storefront (id), + account_id BIGINT NOT NULL REFERENCES account (id), + file_name TEXT NOT NULL, + dialect TEXT NOT NULL, + products_added INTEGER NOT NULL, + products_updated INTEGER NOT NULL, + rows_errored INTEGER NOT NULL, + status TEXT NOT NULL + CHECK (status IN ('applying', 'fetching_images', 'complete', 'complete_with_problems')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ +); +CREATE INDEX import_run_history_idx ON import_run (storefront_id, created_at DESC); + +-- import_run_error — one row per rejected CSV row (PUC-5), merchant-language message. +CREATE TABLE import_run_error ( + id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + run_id BIGINT NOT NULL REFERENCES import_run (id), + line_number INTEGER NOT NULL, + column_name TEXT, + message TEXT NOT NULL +); + +ALTER TABLE product_image + ADD CONSTRAINT product_image_run_fk FOREIGN KEY (import_run_id) REFERENCES import_run (id); diff --git a/backend/requirements.txt b/backend/requirements.txt index ad1b99c..3f005d5 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -5,3 +5,5 @@ psycopg[binary]>=3.1 psycopg-pool>=3.2 pytest>=8.0 import-linter>=2.0 +nh3>=0.2 +python-multipart>=0.0.9 diff --git a/backend/tests/test_migrations.py b/backend/tests/test_migrations.py index 81e9945..4c4341c 100644 --- a/backend/tests/test_migrations.py +++ b/backend/tests/test_migrations.py @@ -1,4 +1,5 @@ import psycopg +import pytest from app.platform import db @@ -12,10 +13,10 @@ def _table_names(conn) -> set[str]: return {r[0] for r in rows} -def test_migrate_from_empty_applies_0001(fresh_db_url): +def test_migrate_from_empty_applies_all(fresh_db_url): with psycopg.connect(fresh_db_url) as conn: applied = db.migrate(conn) - assert applied == ["0001_init.sql"] + assert applied == ["0001_init.sql", "0002_products.sql"] with psycopg.connect(fresh_db_url) as conn: assert _TABLES.issubset(_table_names(conn)) @@ -41,3 +42,24 @@ def test_membership_has_no_unique_account_constraint(fresh_db_url): ).fetchall() defs = " ".join(r[0] for r in rows).lower() assert "unique" not in defs.replace("primary key", "") or "(account_id)" not in defs + + +def test_0002_products_tables_exist(fresh_db_url): + with psycopg.connect(fresh_db_url) as conn: + db.migrate(conn) + for table in ("product", "variant", "product_image", "import_draft", "import_run", "import_run_error"): + assert conn.execute("SELECT to_regclass(%s)", (f"public.{table}",)).fetchone()[0] == table + + +def test_0002_variant_option_combo_unique_treats_nulls_as_equal(fresh_db_url): + # INV-13: the no-option product's single variant has NULL option values; a second + # all-NULL combo must collide (NULLS NOT DISTINCT). + with psycopg.connect(fresh_db_url) as conn: + db.migrate(conn) + sf = conn.execute("INSERT INTO storefront (name) VALUES ('s') RETURNING id").fetchone()[0] + pid = conn.execute( + "INSERT INTO product (storefront_id, handle, title) VALUES (%s,'h','T') RETURNING id", (sf,) + ).fetchone()[0] + conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 1)", (pid,)) + with pytest.raises(psycopg.errors.UniqueViolation): + conn.execute("INSERT INTO variant (product_id, position) VALUES (%s, 2)", (pid,))