Add public user-products storefront API and website docs.

Expose published customer listings under /tenants/:host/user-products (list, search, details, technical-info) and document them in the website API pack.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 00:19:13 +03:30
co-authored by Cursor
parent 158523df7b
commit 953b87b616
32 changed files with 3326 additions and 138 deletions
+188
View File
@@ -0,0 +1,188 @@
-- Customer / user products (stock listings)
-- Like products, but: no variations/options; location (country/city/district);
-- categories come from main product categories via category_assignments;
-- technical data reuses category technical forms.
-- ---------------------------------------------------------------------------
-- entity type for assignments / media attachments
-- ---------------------------------------------------------------------------
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'user_product';
-- ---------------------------------------------------------------------------
-- cities: add district under city (country → province → city → district)
-- ---------------------------------------------------------------------------
ALTER TYPE city_level ADD VALUE IF NOT EXISTS 'district';
CREATE OR REPLACE FUNCTION cities_validate_parent_level()
RETURNS TRIGGER AS $$
DECLARE
parent_level city_level;
BEGIN
IF NEW.level = 'country' THEN
RETURN NEW;
END IF;
SELECT level INTO parent_level FROM cities WHERE id = NEW.parent_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'parent city not found';
END IF;
IF NEW.level = 'province' AND parent_level <> 'country' THEN
RAISE EXCEPTION 'province parent must be a country';
END IF;
IF NEW.level = 'city' AND parent_level <> 'province' THEN
RAISE EXCEPTION 'city parent must be a province';
END IF;
IF NEW.level = 'district' AND parent_level <> 'city' THEN
RAISE EXCEPTION 'district parent must be a city';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- user_products
-- ---------------------------------------------------------------------------
CREATE TABLE user_products (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
content JSONB NOT NULL DEFAULT '{}',
price NUMERIC(12, 2),
compare_at_price NUMERIC(12, 2),
sku VARCHAR(100),
stock_quantity INTEGER,
status content_status NOT NULL DEFAULT 'draft',
featured_media_id BIGINT,
brand_id BIGINT,
country_id BIGINT NOT NULL,
city_id BIGINT NOT NULL,
district_id BIGINT,
sort_order INTEGER NOT NULL DEFAULT 0,
published_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT user_products_business_slug_unique UNIQUE (business_id, slug),
CONSTRAINT user_products_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT user_products_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT user_products_featured_media_id_fkey
FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL,
CONSTRAINT user_products_brand_id_fkey
FOREIGN KEY (brand_id) REFERENCES brands (id) ON DELETE SET NULL,
CONSTRAINT user_products_country_id_fkey
FOREIGN KEY (country_id) REFERENCES cities (id) ON DELETE RESTRICT,
CONSTRAINT user_products_city_id_fkey
FOREIGN KEY (city_id) REFERENCES cities (id) ON DELETE RESTRICT,
CONSTRAINT user_products_district_id_fkey
FOREIGN KEY (district_id) REFERENCES cities (id) ON DELETE SET NULL,
CONSTRAINT user_products_price_non_negative CHECK (price IS NULL OR price >= 0),
CONSTRAINT user_products_compare_price_non_negative CHECK (compare_at_price IS NULL OR compare_at_price >= 0),
CONSTRAINT user_products_stock_non_negative CHECK (stock_quantity IS NULL OR stock_quantity >= 0)
);
CREATE INDEX idx_user_products_business_id ON user_products (business_id);
CREATE INDEX idx_user_products_user_id ON user_products (user_id);
CREATE INDEX idx_user_products_business_user ON user_products (business_id, user_id);
CREATE INDEX idx_user_products_business_status ON user_products (business_id, status);
CREATE INDEX idx_user_products_business_published ON user_products (business_id, published_at DESC);
CREATE INDEX idx_user_products_brand_id ON user_products (brand_id);
CREATE INDEX idx_user_products_country_id ON user_products (country_id);
CREATE INDEX idx_user_products_city_id ON user_products (city_id);
CREATE INDEX idx_user_products_district_id ON user_products (district_id);
CREATE TRIGGER user_products_set_updated_at
BEFORE UPDATE ON user_products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- technical data (same shape as product technical field values)
-- ---------------------------------------------------------------------------
CREATE TABLE user_product_technical_field_values (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_product_id BIGINT NOT NULL,
field_id BIGINT NOT NULL,
text_value TEXT,
option_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT user_product_technical_field_values_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_values_user_product_id_fkey
FOREIGN KEY (user_product_id) REFERENCES user_products (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_values_field_id_fkey
FOREIGN KEY (field_id) REFERENCES category_technical_form_fields (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_values_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE SET NULL,
CONSTRAINT user_product_technical_field_values_unique
UNIQUE (user_product_id, field_id)
);
CREATE INDEX idx_user_product_technical_field_values_user_product_id
ON user_product_technical_field_values (user_product_id);
CREATE INDEX idx_user_product_technical_field_values_business_id
ON user_product_technical_field_values (business_id);
CREATE TRIGGER user_product_technical_field_values_set_updated_at
BEFORE UPDATE ON user_product_technical_field_values
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE user_product_technical_field_value_options (
field_value_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
CONSTRAINT user_product_technical_field_value_options_pkey
PRIMARY KEY (field_value_id, option_id),
CONSTRAINT user_product_technical_field_value_options_field_value_id_fkey
FOREIGN KEY (field_value_id) REFERENCES user_product_technical_field_values (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_value_options_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE CASCADE
);
CREATE INDEX idx_user_product_technical_field_value_options_option_id
ON user_product_technical_field_value_options (option_id);
-- ---------------------------------------------------------------------------
-- permissions (business dashboard moderation)
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View user products', 'user_products.read', 'user_products', 'View customer product listings'),
('Create user products', 'user_products.create', 'user_products', 'Create customer product listings'),
('Update user products', 'user_products.update', 'user_products', 'Edit customer product listings'),
('Delete user products', 'user_products.delete', 'user_products', 'Delete customer product listings')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'user_products.%'
WHERE r.slug IN ('business_owner', 'owner', 'admin')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('user_products.read', 'user_products.create', 'user_products.update')
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'user_products.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,86 @@
-- Allow cities directly under a country (province optional).
-- Seed Iraq, Turkey, UAE + major cities (Iran already seeded in 004_iran_cities).
CREATE OR REPLACE FUNCTION cities_validate_parent_level()
RETURNS TRIGGER AS $$
DECLARE
parent_level city_level;
BEGIN
IF NEW.level = 'country' THEN
RETURN NEW;
END IF;
SELECT level INTO parent_level FROM cities WHERE id = NEW.parent_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'parent city not found';
END IF;
IF NEW.level = 'province' AND parent_level <> 'country' THEN
RAISE EXCEPTION 'province parent must be a country';
END IF;
-- Province is optional: city may hang under a province or directly under a country.
IF NEW.level = 'city' AND parent_level NOT IN ('province', 'country') THEN
RAISE EXCEPTION 'city parent must be a province or country';
END IF;
IF NEW.level = 'district' AND parent_level <> 'city' THEN
RAISE EXCEPTION 'district parent must be a city';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- Countries (skip if slug already exists)
-- ---------------------------------------------------------------------------
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT NULL, 'country', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM (
VALUES
('ایران', 'Iran', '98', 'iran', 1),
('عراق', 'Iraq', '964', 'iraq', 2),
('ترکیه', 'Turkey', '90', 'turkey', 3),
('امارات', 'UAE', '971', 'uae', 4)
) AS v(name_fa, name_en, landline_code, slug, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM cities c WHERE c.slug = v.slug
);
-- ---------------------------------------------------------------------------
-- Major cities under country (direct children; province not required)
-- Iran cities already exist under provinces — API lists them via country parent.
-- ---------------------------------------------------------------------------
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT c.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities c
JOIN (
VALUES
-- Iraq
('iraq', 'بغداد', 'Baghdad', '1', 'baghdad', 1),
('iraq', 'بصره', 'Basra', '40', 'basra', 2),
('iraq', 'اربیل', 'Erbil', '66', 'erbil', 3),
('iraq', 'موصل', 'Mosul', '60', 'mosul', 4),
('iraq', 'نجف', 'Najaf', '33', 'najaf', 5),
('iraq', 'سلیمانیه', 'Sulaymaniyah', '53', 'sulaymaniyah', 6),
('iraq', 'کرکوک', 'Kirkuk', '50', 'kirkuk', 7),
-- Turkey
('turkey', 'استانبول', 'Istanbul', '212', 'istanbul', 1),
('turkey', 'آنکارا', 'Ankara', '312', 'ankara', 2),
('turkey', 'ازمیر', 'Izmir', '232', 'izmir', 3),
('turkey', 'آنتالیا', 'Antalya', '242', 'antalya', 4),
('turkey', 'بورسا', 'Bursa', '224', 'bursa', 5),
('turkey', 'غازی عینتاب', 'Gaziantep', '342', 'gaziantep', 6),
-- UAE
('uae', 'دبی', 'Dubai', '4', 'dubai', 1),
('uae', 'ابوظبی', 'Abu Dhabi', '2', 'abu-dhabi', 2),
('uae', 'شارجه', 'Sharjah', '6', 'sharjah', 3),
('uae', 'عجمان', 'Ajman', '6', 'ajman', 4),
('uae', 'رأس الخیمه', 'Ras Al Khaimah', '7', 'ras-al-khaimah', 5)
) AS v(country_slug, name_fa, name_en, landline_code, slug, sort_order)
ON c.slug = v.country_slug AND c.level = 'country'
WHERE NOT EXISTS (
SELECT 1 FROM cities x WHERE x.slug = v.slug
);
@@ -0,0 +1,113 @@
-- Seed Iran provinces + cities if missing (004_iran_cities may never have been applied).
-- Iran country row is expected from 053 (or earlier).
-- Provinces under Iran
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT c.id, 'province', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities c
CROSS JOIN (
VALUES
('آذربایجان شرقی', 'East Azerbaijan', '041', 'east-azerbaijan', 1),
('آذربایجان غربی', 'West Azerbaijan', '044', 'west-azerbaijan', 2),
('اردبیل', 'Ardabil', '045', 'ardabil', 3),
('اصفهان', 'Isfahan', '031', 'isfahan', 4),
('البرز', 'Alborz', '026', 'alborz', 5),
('ایلام', 'Ilam', '084', 'ilam', 6),
('بوشهر', 'Bushehr', '077', 'bushehr', 7),
('تهران', 'Tehran', '021', 'tehran-province', 8),
('چهارمحال و بختیاری', 'Chaharmahal and Bakhtiari', '038', 'chaharmahal-bakhtiari', 9),
('خراسان جنوبی', 'South Khorasan', '056', 'south-khorasan', 10),
('خراسان رضوی', 'Razavi Khorasan', '051', 'razavi-khorasan', 11),
('خراسان شمالی', 'North Khorasan', '058', 'north-khorasan', 12),
('خوزستان', 'Khuzestan', '061', 'khuzestan', 13),
('زنجان', 'Zanjan', '024', 'zanjan', 14),
('سمنان', 'Semnan', '023', 'semnan', 15),
('سیستان و بلوچستان', 'Sistan and Baluchestan', '054', 'sistan-baluchestan', 16),
('فارس', 'Fars', '071', 'fars', 17),
('قزوین', 'Qazvin', '028', 'qazvin', 18),
('قم', 'Qom', '025', 'qom', 19),
('کردستان', 'Kurdistan', '087', 'kurdistan', 20),
('کرمان', 'Kerman', '034', 'kerman', 21),
('کرمانشاه', 'Kermanshah', '083', 'kermanshah', 22),
('کهگیلویه و بویراحمد', 'Kohgiluyeh and Boyer-Ahmad', '074', 'kohgiluyeh-boyer-ahmad', 23),
('گلستان', 'Golestan', '017', 'golestan', 24),
('گیلان', 'Gilan', '013', 'gilan', 25),
('لرستان', 'Lorestan', '066', 'lorestan', 26),
('مازندران', 'Mazandaran', '011', 'mazandaran', 27),
('مرکزی', 'Markazi', '086', 'markazi', 28),
('هرمزگان', 'Hormozgan', '076', 'hormozgan', 29),
('همدان', 'Hamadan', '081', 'hamadan', 30),
('یزد', 'Yazd', '035', 'yazd', 31)
) AS v(name_fa, name_en, landline_code, slug, sort_order)
WHERE c.slug = 'iran' AND c.level = 'country'
AND NOT EXISTS (SELECT 1 FROM cities x WHERE x.slug = v.slug);
-- Provincial capitals
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities p
JOIN (
VALUES
('east-azerbaijan', 'تبریز', 'Tabriz', '041', 'tabriz', 1),
('west-azerbaijan', 'ارومیه', 'Urmia', '044', 'urmia', 1),
('ardabil', 'اردبیل', 'Ardabil', '045', 'ardabil-city', 1),
('isfahan', 'اصفهان', 'Isfahan', '031', 'isfahan-city', 1),
('alborz', 'کرج', 'Karaj', '026', 'karaj', 1),
('ilam', 'ایلام', 'Ilam', '084', 'ilam-city', 1),
('bushehr', 'بوشهر', 'Bushehr', '077', 'bushehr-city', 1),
('tehran-province', 'تهران', 'Tehran', '021', 'tehran', 1),
('chaharmahal-bakhtiari', 'شهرکرد', 'Shahrekord', '038', 'shahrekord', 1),
('south-khorasan', 'بیرجند', 'Birjand', '056', 'birjand', 1),
('razavi-khorasan', 'مشهد', 'Mashhad', '051', 'mashhad', 1),
('north-khorasan', 'بجنورد', 'Bojnord', '058', 'bojnord', 1),
('khuzestan', 'اهواز', 'Ahvaz', '061', 'ahvaz', 1),
('zanjan', 'زنجان', 'Zanjan', '024', 'zanjan-city', 1),
('semnan', 'سمنان', 'Semnan', '023', 'semnan-city', 1),
('sistan-baluchestan', 'زاهدان', 'Zahedan', '054', 'zahedan', 1),
('fars', 'شیراز', 'Shiraz', '071', 'shiraz', 1),
('qazvin', 'قزوین', 'Qazvin', '028', 'qazvin-city', 1),
('qom', 'قم', 'Qom', '025', 'qom-city', 1),
('kurdistan', 'سنندج', 'Sanandaj', '087', 'sanandaj', 1),
('kerman', 'کرمان', 'Kerman', '034', 'kerman-city', 1),
('kermanshah', 'کرمانشاه', 'Kermanshah', '083', 'kermanshah-city', 1),
('kohgiluyeh-boyer-ahmad', 'یاسوج', 'Yasuj', '074', 'yasuj', 1),
('golestan', 'گرگان', 'Gorgan', '017', 'gorgan', 1),
('gilan', 'رشت', 'Rasht', '013', 'rasht', 1),
('lorestan', 'خرم‌آباد', 'Khorramabad', '066', 'khorramabad', 1),
('mazandaran', 'ساری', 'Sari', '011', 'sari', 1),
('markazi', 'اراک', 'Arak', '086', 'arak', 1),
('hormozgan', 'بندرعباس', 'Bandar Abbas', '076', 'bandar-abbas', 1),
('hamadan', 'همدان', 'Hamadan', '081', 'hamadan-city', 1),
('yazd', 'یزد', 'Yazd', '035', 'yazd-city', 1)
) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order)
ON p.slug = v.province_slug AND p.level = 'province'
WHERE NOT EXISTS (SELECT 1 FROM cities x WHERE x.slug = v.slug);
-- Additional major cities
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities p
JOIN (
VALUES
('tehran-province', 'ری', 'Rey', '021', 'rey', 2),
('tehran-province', 'شهریار', 'Shahriar', '021', 'shahriar', 3),
('tehran-province', 'ورامین', 'Varamin', '021', 'varamin', 4),
('isfahan', 'کاشان', 'Kashan', '031', 'kashan', 2),
('isfahan', 'نجف‌آباد', 'Najafabad', '031', 'najafabad', 3),
('fars', 'مرودشت', 'Marvdasht', '071', 'marvdasht', 2),
('fars', 'جهرم', 'Jahrom', '071', 'jahrom', 3),
('khuzestan', 'آبادان', 'Abadan', '061', 'abadan', 2),
('khuzestan', 'دزفول', 'Dezful', '061', 'dezful', 3),
('razavi-khorasan', 'نیشابور', 'Neyshabur', '051', 'neyshabur', 2),
('razavi-khorasan', 'سبزوار', 'Sabzevar', '051', 'sabzevar', 3),
('mazandaran', 'آمل', 'Amol', '011', 'amol', 2),
('mazandaran', 'بابل', 'Babol', '011', 'babol', 3),
('gilan', 'انزلی', 'Bandar Anzali', '013', 'bandar-anzali', 2),
('east-azerbaijan', 'مراغه', 'Maragheh', '041', 'maragheh', 2),
('kerman', 'رفسنجان', 'Rafsanjan', '034', 'rafsanjan', 2),
('alborz', 'فردیس', 'Fardis', '026', 'fardis', 2)
) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order)
ON p.slug = v.province_slug AND p.level = 'province'
WHERE NOT EXISTS (SELECT 1 FROM cities x WHERE x.slug = v.slug);
SELECT setval(pg_get_serial_sequence('cities', 'id'), COALESCE((SELECT MAX(id) FROM cities), 1));
@@ -0,0 +1,18 @@
-- User product listing extras: currency, delivery note, condition, technical notes
CREATE TYPE user_product_condition AS ENUM (
'new',
'stock',
'needs_repair',
'scrap'
);
ALTER TABLE user_products
ADD COLUMN IF NOT EXISTS price_currency VARCHAR(8) NOT NULL DEFAULT 'IRT',
ADD COLUMN IF NOT EXISTS delivery_note TEXT,
ADD COLUMN IF NOT EXISTS condition user_product_condition NOT NULL DEFAULT 'new',
ADD COLUMN IF NOT EXISTS technical_notes TEXT;
ALTER TABLE user_products
ADD CONSTRAINT user_products_price_currency_format
CHECK (price_currency ~ '^[A-Z]{3,8}$');
@@ -0,0 +1 @@
ALTER TYPE content_status ADD VALUE IF NOT EXISTS 'rejected';