-- Atlas Seller Kit: collection box, product editor, publishing and online listing schema -- PostgreSQL 15+. Run once after 001_foundation.sql through the migration tool. SET search_path TO atlas, public; CREATE TYPE collection_source_kind AS ENUM ('platform', 'manual'); CREATE TYPE collection_item_status AS ENUM ( 'collecting', 'received', 'materializing', 'ready_for_edit', 'needs_review', 'failed', 'archived', 'deleted' ); CREATE TYPE attribute_scope AS ENUM ('common', 'variant'); CREATE TYPE data_confirmation_status AS ENUM ('confirmed', 'ambiguous', 'missing'); CREATE TYPE platform_variant_listing_status AS ENUM ('not_listed', 'submitting', 'imported', 'failed'); CREATE TYPE media_candidate_role AS ENUM ('source_main', 'source_sku', 'source_detail', 'ai_generated', 'manual_upload'); CREATE TYPE media_derivative_kind AS ENUM ('watermark'); CREATE TYPE processing_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); CREATE TYPE seller_enrichment_observation_status AS ENUM ('observed', 'unavailable', 'failed'); CREATE TYPE ai_task_kind AS ENUM ('copy', 'category_suggestion', 'image_suite', 'dimension_image'); CREATE TYPE publish_job_status AS ENUM ('queued', 'running', 'succeeded', 'partially_succeeded', 'failed', 'cancelled'); CREATE TYPE publish_job_item_status AS ENUM ('queued', 'submitting', 'polling', 'imported', 'failed', 'skipped'); CREATE TYPE platform_listing_source AS ENUM ('atlas_published', 'external_sync'); CREATE TYPE platform_sync_status AS ENUM ('queued', 'running', 'succeeded', 'failed', 'cancelled'); -- Default can later be changed by subscription/operations policy without changing collection-item rows. ALTER TABLE workspaces ADD COLUMN collection_item_limit INTEGER NOT NULL DEFAULT 1000 CHECK (collection_item_limit >= 0); -- A collection item is workspace-wide and intentionally has no target platform or store. CREATE TABLE collection_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE RESTRICT, source_kind collection_source_kind NOT NULL, source_platform_id UUID REFERENCES platforms(id) ON DELETE RESTRICT, source_item_key TEXT NOT NULL, source_url TEXT, source_title TEXT, source_currency_code CHAR(3) REFERENCES currencies(code), source_min_price NUMERIC(18,4) CHECK (source_min_price IS NULL OR source_min_price >= 0), source_max_price NUMERIC(18,4) CHECK (source_max_price IS NULL OR source_max_price >= 0), note TEXT, status collection_item_status NOT NULL DEFAULT 'received', raw_evidence JSONB NOT NULL DEFAULT '{}'::jsonb, normalized_payload JSONB NOT NULL DEFAULT '{}'::jsonb, parser_version TEXT, collected_at TIMESTAMPTZ, last_error JSONB, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), revision BIGINT NOT NULL DEFAULT 1 CHECK (revision > 0), UNIQUE (workspace_id, id), CHECK ( (source_kind = 'platform' AND source_platform_id IS NOT NULL) OR (source_kind = 'manual' AND source_platform_id IS NULL) ), CHECK (source_max_price IS NULL OR source_min_price IS NULL OR source_max_price >= source_min_price), CHECK ((status = 'deleted') = (deleted_at IS NOT NULL)) ); CREATE UNIQUE INDEX ux_collection_items_active_source_identity ON collection_items (workspace_id, source_platform_id, source_item_key) WHERE source_platform_id IS NOT NULL AND deleted_at IS NULL; CREATE INDEX ix_collection_items_workspace_list ON collection_items (workspace_id, updated_at DESC) WHERE deleted_at IS NULL; CREATE INDEX ix_collection_items_workspace_source_status ON collection_items (workspace_id, source_platform_id, status, updated_at DESC) WHERE deleted_at IS NULL; -- Locking the one workspace row serializes only collection-box creates/restores in that workspace. CREATE OR REPLACE FUNCTION enforce_collection_item_limit() RETURNS TRIGGER LANGUAGE plpgsql AS $$ DECLARE configured_limit INTEGER; active_count INTEGER; BEGIN IF (TG_OP = 'INSERT' AND NEW.deleted_at IS NULL) OR (TG_OP = 'UPDATE' AND OLD.deleted_at IS NOT NULL AND NEW.deleted_at IS NULL) THEN SELECT collection_item_limit INTO configured_limit FROM atlas.workspaces WHERE id = NEW.workspace_id FOR UPDATE; SELECT count(*) INTO active_count FROM atlas.collection_items WHERE workspace_id = NEW.workspace_id AND deleted_at IS NULL; IF active_count >= configured_limit THEN RAISE EXCEPTION 'collection item limit (%) reached for workspace %', configured_limit, NEW.workspace_id USING ERRCODE = 'check_violation'; END IF; END IF; RETURN NEW; END; $$; CREATE TRIGGER enforce_collection_item_limit_before_write BEFORE INSERT OR UPDATE OF deleted_at ON collection_items FOR EACH ROW EXECUTE FUNCTION enforce_collection_item_limit(); -- products and product_variants are Atlas collection-box drafts, not online products. CREATE TABLE products ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL, source_title TEXT, brand TEXT, model TEXT, barcode TEXT, purchase_url TEXT, supply_note TEXT, default_physical_length_mm NUMERIC(12,3) CHECK (default_physical_length_mm IS NULL OR default_physical_length_mm > 0), default_physical_width_mm NUMERIC(12,3) CHECK (default_physical_width_mm IS NULL OR default_physical_width_mm > 0), default_physical_height_mm NUMERIC(12,3) CHECK (default_physical_height_mm IS NULL OR default_physical_height_mm > 0), default_net_weight_g NUMERIC(12,3) CHECK (default_net_weight_g IS NULL OR default_net_weight_g > 0), default_package_length_mm NUMERIC(12,3) CHECK (default_package_length_mm IS NULL OR default_package_length_mm > 0), default_package_width_mm NUMERIC(12,3) CHECK (default_package_width_mm IS NULL OR default_package_width_mm > 0), default_package_height_mm NUMERIC(12,3) CHECK (default_package_height_mm IS NULL OR default_package_height_mm > 0), default_shipping_weight_g NUMERIC(12,3) CHECK (default_shipping_weight_g IS NULL OR default_shipping_weight_g > 0), physical_spec_status data_confirmation_status NOT NULL DEFAULT 'missing', package_spec_status data_confirmation_status NOT NULL DEFAULT 'missing', version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), UNIQUE (workspace_id, collection_item_id) ); CREATE TABLE product_variants ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL, source_variant_key TEXT, display_name TEXT, source_sku TEXT, sort_order INTEGER NOT NULL DEFAULT 0, purchase_cost_cny NUMERIC(18,4) CHECK (purchase_cost_cny IS NULL OR purchase_cost_cny >= 0), specification_values JSONB NOT NULL DEFAULT '[]'::jsonb, physical_length_mm NUMERIC(12,3) CHECK (physical_length_mm IS NULL OR physical_length_mm > 0), physical_width_mm NUMERIC(12,3) CHECK (physical_width_mm IS NULL OR physical_width_mm > 0), physical_height_mm NUMERIC(12,3) CHECK (physical_height_mm IS NULL OR physical_height_mm > 0), net_weight_g NUMERIC(12,3) CHECK (net_weight_g IS NULL OR net_weight_g > 0), package_length_mm NUMERIC(12,3) CHECK (package_length_mm IS NULL OR package_length_mm > 0), package_width_mm NUMERIC(12,3) CHECK (package_width_mm IS NULL OR package_width_mm > 0), package_height_mm NUMERIC(12,3) CHECK (package_height_mm IS NULL OR package_height_mm > 0), shipping_weight_g NUMERIC(12,3) CHECK (shipping_weight_g IS NULL OR shipping_weight_g > 0), physical_spec_status data_confirmation_status, package_spec_status data_confirmation_status, deleted_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id) ); CREATE UNIQUE INDEX ux_product_variants_source_key ON product_variants (workspace_id, product_id, source_variant_key) WHERE source_variant_key IS NOT NULL; CREATE INDEX ix_product_variants_product ON product_variants (workspace_id, product_id, sort_order) WHERE deleted_at IS NULL; -- Source image URLs are untrusted remote inputs. Transfer state exists before a media_asset can be created. CREATE TABLE collection_asset_transfers ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL, product_id UUID NOT NULL, source_variant_id UUID, role media_candidate_role NOT NULL, source_url TEXT NOT NULL, referer_url TEXT, source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1), sort_order INTEGER NOT NULL DEFAULT 0, status processing_status NOT NULL DEFAULT 'queued', asset_id UUID, error_payload JSONB, task_key UUID NOT NULL DEFAULT gen_random_uuid(), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, CHECK ((status = 'succeeded') = (asset_id IS NOT NULL)), CHECK (completed_at IS NULL OR completed_at >= created_at), UNIQUE (workspace_id, task_key) ); CREATE INDEX ix_collection_asset_transfers_workspace_status ON collection_asset_transfers (workspace_id, status, created_at DESC); -- Sanitized results from Ozon Seller web local-observation calls. No Cookie or authorization data is stored. CREATE TABLE collection_seller_enrichment_observations ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, collection_item_id UUID NOT NULL, source_variant_id UUID, interface_code TEXT NOT NULL, status seller_enrichment_observation_status NOT NULL, sanitized_payload JSONB NOT NULL DEFAULT '{}'::jsonb, unavailable_reason TEXT, parser_version TEXT, observed_at TIMESTAMPTZ NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, collection_item_id) REFERENCES collection_items(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, CHECK ((status = 'observed') = (unavailable_reason IS NULL)), UNIQUE (workspace_id, id) ); CREATE INDEX ix_seller_enrichment_observations_collection ON collection_seller_enrichment_observations (workspace_id, collection_item_id, observed_at DESC); -- One collection draft can have a separate profile for every target platform. CREATE TABLE product_platform_profiles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL, platform_id UUID NOT NULL, target_store_id UUID NOT NULL, market_code TEXT NOT NULL DEFAULT 'RU', fulfillment fulfillment_mode NOT NULL DEFAULT 'unknown', title TEXT, description TEXT, brand TEXT, model TEXT, tags JSONB NOT NULL DEFAULT '[]'::jsonb, platform_product_category_id UUID, platform_type_id TEXT, category_source TEXT CHECK (category_source IN ('source_capture', 'ai_suggestion', 'manual')), version BIGINT NOT NULL DEFAULT 1 CHECK (version > 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, target_store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, FOREIGN KEY (platform_id, platform_product_category_id) REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), UNIQUE (workspace_id, id, platform_id), UNIQUE (workspace_id, product_id, platform_id) ); CREATE INDEX ix_product_platform_profiles_store ON product_platform_profiles (workspace_id, target_store_id, updated_at DESC); -- Stores every SKU's first-import state in the current platform profile. Imported rows are never reused for re-import. CREATE TABLE product_variant_platform_listings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, profile_id UUID NOT NULL, platform_id UUID NOT NULL, product_variant_id UUID NOT NULL, store_id UUID NOT NULL, offer_id TEXT, currency_code CHAR(3) REFERENCES currencies(code), selling_price NUMERIC(18,4) CHECK (selling_price IS NULL OR selling_price >= 0), list_price NUMERIC(18,4) CHECK (list_price IS NULL OR list_price >= 0), pricing_quote_id UUID, status platform_variant_listing_status NOT NULL DEFAULT 'not_listed', platform_product_id TEXT, first_imported_at TIMESTAMPTZ, last_publish_job_item_id UUID, last_error JSONB, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, profile_id, platform_id) REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), UNIQUE (workspace_id, profile_id, product_variant_id), CHECK (list_price IS NULL OR selling_price IS NULL OR list_price >= selling_price), CHECK ( (status = 'imported' AND first_imported_at IS NOT NULL AND platform_product_id IS NOT NULL) OR status <> 'imported' ) ); CREATE UNIQUE INDEX ux_variant_platform_listings_store_offer ON product_variant_platform_listings (workspace_id, store_id, platform_id, offer_id) WHERE offer_id IS NOT NULL; CREATE INDEX ix_variant_platform_listings_profile_status ON product_variant_platform_listings (workspace_id, profile_id, status); CREATE TABLE product_attributes ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, profile_id UUID NOT NULL, product_variant_id UUID, scope attribute_scope NOT NULL, platform_attribute_id TEXT NOT NULL, complex_id TEXT NOT NULL DEFAULT '0', values JSONB NOT NULL DEFAULT '[]'::jsonb, language_code TEXT, source TEXT NOT NULL DEFAULT 'manual', confirmation_status data_confirmation_status NOT NULL DEFAULT 'confirmed', raw_value TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE, CHECK ((scope = 'common' AND product_variant_id IS NULL) OR (scope = 'variant' AND product_variant_id IS NOT NULL)) ); CREATE UNIQUE INDEX ux_product_attributes_common ON product_attributes (workspace_id, profile_id, platform_attribute_id, complex_id, COALESCE(language_code, '')) WHERE scope = 'common'; CREATE UNIQUE INDEX ux_product_attributes_variant ON product_attributes (workspace_id, profile_id, product_variant_id, platform_attribute_id, complex_id, COALESCE(language_code, '')) WHERE scope = 'variant'; CREATE TABLE product_media_candidates ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL, asset_id UUID NOT NULL, source_variant_id UUID, role media_candidate_role NOT NULL, source_url TEXT, source_confidence NUMERIC(5,4) CHECK (source_confidence IS NULL OR source_confidence BETWEEN 0 AND 1), sort_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, source_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, UNIQUE (workspace_id, product_id, asset_id, role) ); -- Selected media is platform-SKU-specific because each platform can have different image limits and watermarked variants. CREATE TABLE product_variant_listing_media ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, variant_listing_id UUID NOT NULL, asset_id UUID NOT NULL, source_asset_id UUID, sort_order SMALLINT NOT NULL CHECK (sort_order >= 0), created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, variant_listing_id) REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, variant_listing_id, sort_order), UNIQUE (workspace_id, variant_listing_id, asset_id) ); CREATE TABLE media_derivatives ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, kind media_derivative_kind NOT NULL, source_asset_id UUID NOT NULL, output_asset_id UUID, watermark_template_version_id UUID, status processing_status NOT NULL DEFAULT 'queued', task_key UUID NOT NULL DEFAULT gen_random_uuid(), error_payload JSONB, created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), completed_at TIMESTAMPTZ, FOREIGN KEY (workspace_id, source_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, output_asset_id) REFERENCES media_assets(workspace_id, id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, watermark_template_version_id) REFERENCES watermark_template_versions(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, task_key), CHECK ((status = 'succeeded') = (output_asset_id IS NOT NULL)), CHECK (completed_at IS NULL OR completed_at >= created_at) ); CREATE TABLE product_ai_tasks ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, product_id UUID NOT NULL, profile_id UUID, product_variant_id UUID, kind ai_task_kind NOT NULL, status processing_status NOT NULL DEFAULT 'queued', provider_code TEXT, model_code TEXT, input_snapshot JSONB NOT NULL, output_payload JSONB, error_payload JSONB, queue_job_id TEXT, created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, FOREIGN KEY (workspace_id, product_id) REFERENCES products(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE CASCADE, UNIQUE (workspace_id, id), CHECK (completed_at IS NULL OR completed_at >= created_at) ); CREATE INDEX ix_product_ai_tasks_workspace_status ON product_ai_tasks (workspace_id, status, created_at DESC); CREATE TABLE ai_category_suggestions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, ai_task_id UUID NOT NULL, profile_id UUID NOT NULL, platform_id UUID NOT NULL, platform_product_category_id UUID NOT NULL, platform_type_id TEXT, confidence NUMERIC(5,4) CHECK (confidence BETWEEN 0 AND 1), rationale TEXT, rank SMALLINT NOT NULL CHECK (rank > 0), applied_at TIMESTAMPTZ, applied_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, ai_task_id) REFERENCES product_ai_tasks(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, profile_id, platform_id) REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE CASCADE, FOREIGN KEY (platform_id, platform_product_category_id) REFERENCES platform_product_categories(platform_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, ai_task_id, rank) ); -- Durable publish intent and per-SKU Ozon import outcomes. BullMQ job ids are auxiliary, never the only state. CREATE TABLE product_publish_jobs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, profile_id UUID NOT NULL, platform_id UUID NOT NULL, store_id UUID NOT NULL, status publish_job_status NOT NULL DEFAULT 'queued', idempotency_key UUID NOT NULL DEFAULT gen_random_uuid(), queue_job_id TEXT, request_snapshot JSONB NOT NULL, error_payload JSONB, submitted_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, profile_id, platform_id) REFERENCES product_platform_profiles(workspace_id, id, platform_id) ON DELETE RESTRICT, FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), UNIQUE (workspace_id, idempotency_key), CHECK (completed_at IS NULL OR completed_at >= created_at) ); CREATE TABLE product_publish_job_items ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, publish_job_id UUID NOT NULL, variant_listing_id UUID NOT NULL, status publish_job_item_status NOT NULL DEFAULT 'queued', platform_task_id TEXT, platform_product_id TEXT, request_snapshot JSONB NOT NULL, response_snapshot JSONB, error_payload JSONB, submitted_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, publish_job_id) REFERENCES product_publish_jobs(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, variant_listing_id) REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE RESTRICT, UNIQUE (workspace_id, id), UNIQUE (workspace_id, publish_job_id, variant_listing_id), CHECK (completed_at IS NULL OR completed_at >= created_at) ); ALTER TABLE product_variant_platform_listings ADD CONSTRAINT fk_variant_listing_last_publish_job_item FOREIGN KEY (last_publish_job_item_id) REFERENCES product_publish_job_items(id) ON DELETE SET NULL; -- Online products are a separate, store-owned Ozon mirror. They never become cross-platform collection-box drafts. CREATE TABLE platform_product_sync_runs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, store_id UUID NOT NULL, platform_id UUID NOT NULL, status platform_sync_status NOT NULL DEFAULT 'queued', trigger_source TEXT NOT NULL CHECK (trigger_source IN ('manual', 'scheduled', 'recovery')), queue_job_id TEXT, cursor_started TEXT, cursor_finished TEXT, listed_count INTEGER NOT NULL DEFAULT 0 CHECK (listed_count >= 0), upserted_count INTEGER NOT NULL DEFAULT 0 CHECK (upserted_count >= 0), failed_count INTEGER NOT NULL DEFAULT 0 CHECK (failed_count >= 0), error_payload JSONB, started_at TIMESTAMPTZ, completed_at TIMESTAMPTZ, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, UNIQUE (workspace_id, id), CHECK (completed_at IS NULL OR completed_at >= created_at) ); CREATE INDEX ix_platform_product_sync_runs_store ON platform_product_sync_runs (workspace_id, store_id, created_at DESC); CREATE UNIQUE INDEX ux_platform_product_sync_runs_one_active_per_store ON platform_product_sync_runs (workspace_id, store_id) WHERE status IN ('queued', 'running'); CREATE TABLE platform_listings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, store_id UUID NOT NULL, platform_id UUID NOT NULL, remote_product_id TEXT NOT NULL, offer_id TEXT NOT NULL, remote_sku TEXT, name TEXT, currency_code CHAR(3) REFERENCES currencies(code), selling_price NUMERIC(18,4), list_price NUMERIC(18,4), visible BOOLEAN, remote_state TEXT, moderation_status TEXT, validation_status TEXT, stock_summary JSONB NOT NULL DEFAULT '{}'::jsonb, image_summary JSONB NOT NULL DEFAULT '[]'::jsonb, error_payload JSONB NOT NULL DEFAULT '[]'::jsonb, visibility_details JSONB NOT NULL DEFAULT '{}'::jsonb, source platform_listing_source NOT NULL, atlas_variant_listing_id UUID, last_sync_run_id UUID, last_synced_at TIMESTAMPTZ NOT NULL, missing_since TIMESTAMPTZ, archived_at TIMESTAMPTZ, raw_payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, store_id, platform_id) REFERENCES stores(workspace_id, id, platform_id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, atlas_variant_listing_id) REFERENCES product_variant_platform_listings(workspace_id, id) ON DELETE SET NULL, FOREIGN KEY (workspace_id, last_sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE SET NULL, UNIQUE (workspace_id, id), UNIQUE (workspace_id, store_id, platform_id, remote_product_id), UNIQUE (workspace_id, store_id, platform_id, offer_id) ); CREATE INDEX ix_platform_listings_store_visibility ON platform_listings (workspace_id, store_id, visible, updated_at DESC); CREATE INDEX ix_platform_listings_atlas_mapping ON platform_listings (workspace_id, atlas_variant_listing_id) WHERE atlas_variant_listing_id IS NOT NULL; CREATE TABLE platform_listing_sync_snapshots ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), workspace_id UUID NOT NULL, platform_listing_id UUID NOT NULL, sync_run_id UUID NOT NULL, payload JSONB NOT NULL, created_at TIMESTAMPTZ NOT NULL DEFAULT now(), FOREIGN KEY (workspace_id, platform_listing_id) REFERENCES platform_listings(workspace_id, id) ON DELETE CASCADE, FOREIGN KEY (workspace_id, sync_run_id) REFERENCES platform_product_sync_runs(workspace_id, id) ON DELETE CASCADE, UNIQUE (workspace_id, platform_listing_id, sync_run_id) ); -- The foundation migration intentionally created product_variant_id as a logical FK. ALTER TABLE pricing_quotes ADD COLUMN product_platform_profile_id UUID, ADD CONSTRAINT fk_pricing_quotes_product_variant FOREIGN KEY (workspace_id, product_variant_id) REFERENCES product_variants(workspace_id, id) ON DELETE SET NULL, ADD CONSTRAINT fk_pricing_quotes_product_profile FOREIGN KEY (workspace_id, product_platform_profile_id) REFERENCES product_platform_profiles(workspace_id, id) ON DELETE SET NULL; CREATE OR REPLACE FUNCTION set_row_updated_at() RETURNS TRIGGER LANGUAGE plpgsql AS $$ BEGIN NEW.updated_at = now(); RETURN NEW; END; $$; DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'product_platform_profiles', 'product_variant_platform_listings', 'product_attributes', 'product_variant_listing_media', 'product_publish_jobs', 'product_publish_job_items', 'platform_listings' ] LOOP EXECUTE format('CREATE TRIGGER set_%I_updated_at BEFORE UPDATE ON %I FOR EACH ROW EXECUTE FUNCTION set_row_updated_at()', table_name, table_name); END LOOP; END $$; -- Tenant isolation. app_current_workspace_id() is defined in 001_foundation.sql. DO $$ DECLARE table_name TEXT; BEGIN FOREACH table_name IN ARRAY ARRAY[ 'collection_items', 'products', 'product_variants', 'collection_asset_transfers', 'collection_seller_enrichment_observations', 'product_platform_profiles', 'product_variant_platform_listings', 'product_attributes', 'product_media_candidates', 'product_variant_listing_media', 'media_derivatives', 'product_ai_tasks', 'ai_category_suggestions', 'product_publish_jobs', 'product_publish_job_items', 'platform_product_sync_runs', 'platform_listings', 'platform_listing_sync_snapshots' ] LOOP EXECUTE format('ALTER TABLE %I ENABLE ROW LEVEL SECURITY', table_name); EXECUTE format('ALTER TABLE %I FORCE ROW LEVEL SECURITY', table_name); EXECUTE format( 'CREATE POLICY workspace_isolation ON %I USING (workspace_id = app_current_workspace_id()) WITH CHECK (workspace_id = app_current_workspace_id())', table_name ); END LOOP; END $$;