Files
atlas-seller-kit/docs/architecture/sql/002_product-domain.sql
T

592 lines
29 KiB
PL/PgSQL

-- 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 $$;