mirror of
https://git.meshkee.com/Meshkee/backend.git
synced 2026-08-11 22:30:59 +04:30
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:
co-authored by
Cursor
parent
158523df7b
commit
953b87b616
@@ -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';
|
||||
+40
-5
@@ -1,7 +1,7 @@
|
||||
# Meshkee CMS API — Project Context
|
||||
|
||||
> Living reference for developers and AI assistants working on this codebase.
|
||||
> Last updated: August 5, 2026
|
||||
> Last updated: August 9, 2026
|
||||
|
||||
## What This Project Is
|
||||
|
||||
@@ -80,6 +80,8 @@ src/
|
||||
├── website-docs/ # Public website API docs pack
|
||||
├── invoices/ # Platform invoices + item templates (super-admin; business-ready schema)
|
||||
├── public-sms/ # Partner SMS gateway (API key + domain allowlist → Gama)
|
||||
├── favorites/ # Customer product favorites
|
||||
├── user-products/ # Customer self-service stock listings (`my-user-products`)
|
||||
├── prisma/ # PrismaModule + PrismaService
|
||||
├── redis/ # Redis client + OTP helpers
|
||||
└── common/ # Shared interceptors (BigInt serialization)
|
||||
@@ -159,6 +161,10 @@ Example super admin: `+989121111111` / `password`
|
||||
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
|
||||
| `040_invoice_public_id.sql` | Opaque `public_id` for unguessable public invoice links |
|
||||
| `049_user_name_en.sql` | Optional `users.first_name_en` / `last_name_en` for EN display names |
|
||||
| `052_user_products.sql` | Customer stock listings (`user_products`) + technical values; `cities.level` adds `district`; `media_entity_type` adds `user_product` |
|
||||
| `053_cities_country_optional_province.sql` | City may hang under country (province optional); seed Iraq/Turkey/UAE + major cities |
|
||||
| `054_seed_iran_provinces_cities.sql` | Seed Iran provinces + cities when missing (004 seed may never have run) |
|
||||
| `055_user_products_listing_fields.sql` | User product listing fields: `price_currency`, `delivery_note`, `condition` (`user_product_condition`), `technical_notes` |
|
||||
|
||||
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
|
||||
|
||||
@@ -170,22 +176,25 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
|
||||
|
||||
| Enum | Values |
|
||||
|------|--------|
|
||||
| `MediaEntityType` | `product`, `blog`, `portfolio` |
|
||||
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
|
||||
| `ContentStatus` | `draft`, `published`, `archived` |
|
||||
| `VariationType` | `color`, `size`, `custom` |
|
||||
| `CityLevel` | `country`, `province`, `city`, `district` |
|
||||
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
|
||||
| `OrderSource` | `website`, `admin` |
|
||||
| `InvoiceOwnerScope` | `platform`, `business` |
|
||||
| `InvoiceStatus` | `draft`, `issued`, `approved`, `paid`, `cancelled` |
|
||||
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
|
||||
| `UserProductCondition` | `new`, `stock`, `needs_repair`, `scrap` |
|
||||
| `MediaType` | `image`, `video` |
|
||||
|
||||
### Core relationships
|
||||
|
||||
```
|
||||
Business 1──* Domain
|
||||
Business 1──* Category (entityType: product|blog|portfolio)
|
||||
Business 1──* Category (entityType: product|blog|portfolio|customer)
|
||||
Business 1──* Product
|
||||
Business 1──* UserProduct (customer stock; no variations; location + technical data)
|
||||
Business 1──* Media
|
||||
|
||||
Category 1──* CategoryVariation 1──* CategoryVariationOption
|
||||
@@ -194,12 +203,16 @@ CategoryTechnicalFormField 1──* CategoryTechnicalFormFieldOption
|
||||
|
||||
Product *──0..1 Category (via CategoryAssignment)
|
||||
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
|
||||
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
|
||||
Product 1──0..1 StoreItem (one shop listing per product)
|
||||
StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo)
|
||||
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||
|
||||
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
|
||||
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
|
||||
UserProduct → City (country, city, optional district)
|
||||
User 1──* UserProduct
|
||||
|
||||
Business 1──* Cart (per customer) 1──* CartItem → ProductVariant
|
||||
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
|
||||
User 1──* Cart, Order (as customer)
|
||||
@@ -295,6 +308,27 @@ Pattern: `/businesses/:businessId/<resource>`
|
||||
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
|
||||
| GET/PUT | `/products/:id/technical-info` | Product technical data |
|
||||
|
||||
#### My user products (customer — JWT, must be business customer)
|
||||
|
||||
Base: `/businesses/:businessId/my-user-products`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | List current user's user products (paginated) |
|
||||
| POST | `/` | Create draft user product (category, location, condition, optional technical values) |
|
||||
| GET | `/categories` | Active product categories for picker (`id`, `name`, `nameFa`, `parentId`) |
|
||||
| GET | `/categories/:categoryId/technical-form` | Category technical form (customer access; no `categories.read`) |
|
||||
|
||||
#### Public user products (storefront — no auth)
|
||||
|
||||
Base: `/tenants/:host/user-products`
|
||||
|
||||
| Method | Path | Description |
|
||||
|--------|------|-------------|
|
||||
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) |
|
||||
| GET | `/:slug` | Details + gallery (`images`, `galleryMediaIds`) + technical values |
|
||||
| GET | `/:slug/technical-info` | Category technical form + values |
|
||||
|
||||
#### Cart (customer — JWT, must be business customer)
|
||||
|
||||
| Method | Path | Description |
|
||||
@@ -583,7 +617,8 @@ See `.env.example` for the full list. Key groups:
|
||||
| Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes |
|
||||
| Customer dashboard | Partial | No | Register only | Yes |
|
||||
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
|
||||
| Customer favorites | — | `favorites.*` seeded | No | No |
|
||||
| Customer favorites | — | `favorites.*` seeded | Partial | Yes |
|
||||
| Customer user products | Yes (`052`+`055`) | Admin `user_products.*` + customer JWT | Yes (`my-user-products`, admin, public tenants) | Yes |
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -31,10 +31,17 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
### Typical bootstrap sequence
|
||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
|
||||
3. Catalog: categories, products, store-items
|
||||
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
||||
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
||||
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||
|
||||
### User products (customer listings)
|
||||
Public marketplace listings owned by customers — not catalog `products`.
|
||||
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
|
||||
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery
|
||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
"key": "productSlug",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "userProductId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "userProductSlug",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "blogId",
|
||||
"value": ""
|
||||
@@ -1101,6 +1109,133 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "User Products",
|
||||
"item": [
|
||||
{
|
||||
"name": "List published user products",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"if (pm.response.code === 200) {",
|
||||
" const json = pm.response.json();",
|
||||
" if (json.items?.[0]?.id) pm.collectionVariables.set('userProductId', json.items[0].id);",
|
||||
" if (json.items?.[0]?.slug) pm.collectionVariables.set('userProductSlug', json.items[0].slug);",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?page=1&pageSize=12",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"tenants",
|
||||
"{{domain}}",
|
||||
"user-products"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "pageSize",
|
||||
"value": "12"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "q",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "categoryId",
|
||||
"value": "{{categoryId}}",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "cityId",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "countryId",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "condition",
|
||||
"value": "new",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "promoted",
|
||||
"value": "true",
|
||||
"disabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Search user products",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?q=boiler&page=1&pageSize=12",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"tenants",
|
||||
"{{domain}}",
|
||||
"user-products"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "q",
|
||||
"value": "boiler"
|
||||
},
|
||||
{
|
||||
"key": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "pageSize",
|
||||
"value": "12"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get user product by slug",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get user product technical info by slug",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Store Items",
|
||||
"item": [
|
||||
|
||||
@@ -88,10 +88,17 @@
|
||||
<ol>
|
||||
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
|
||||
<li><code>GET /tenants/{domain}</code> → <code>businessId</code>.</li>
|
||||
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
|
||||
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
|
||||
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
|
||||
</ol>
|
||||
|
||||
<h2>User products (customer listings)</h2>
|
||||
<p>
|
||||
Marketplace-style stock listings created by customers. Public read-only under
|
||||
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
|
||||
See OpenAPI tag <strong>User Products</strong>.
|
||||
</p>
|
||||
|
||||
<h2>For a new website AI / designer</h2>
|
||||
<ol>
|
||||
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
{ "name": "Homepage" },
|
||||
{ "name": "Categories" },
|
||||
{ "name": "Products" },
|
||||
{ "name": "User Products" },
|
||||
{ "name": "Store" },
|
||||
{ "name": "Blogs" },
|
||||
{ "name": "Portfolios" },
|
||||
@@ -210,6 +211,66 @@
|
||||
"responses": { "200": { "description": "{ form, values }" } }
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/user-products": {
|
||||
"get": {
|
||||
"tags": ["User Products"],
|
||||
"summary": "List published customer / stock listings",
|
||||
"description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/domain" },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
|
||||
{ "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } },
|
||||
{ "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } },
|
||||
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "cityId", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "countryId", "in": "query", "schema": { "type": "string" } },
|
||||
{
|
||||
"name": "condition",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["new", "stock", "needs_repair", "scrap"]
|
||||
}
|
||||
},
|
||||
{ "name": "promoted", "in": "query", "schema": { "type": "boolean" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/user-products/{slug}": {
|
||||
"get": {
|
||||
"tags": ["User Products"],
|
||||
"summary": "User product details by slug",
|
||||
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/domain" },
|
||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug"
|
||||
},
|
||||
"404": { "description": "Not found or not published" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/user-products/{slug}/technical-info": {
|
||||
"get": {
|
||||
"tags": ["User Products"],
|
||||
"summary": "User product technical form + values",
|
||||
"description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/domain" },
|
||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "{ form, values }" } }
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/store-items": {
|
||||
"get": {
|
||||
"tags": ["Store"],
|
||||
|
||||
+116
-11
@@ -14,16 +14,17 @@ model User {
|
||||
email String? @db.VarChar(255)
|
||||
firstName String? @map("first_name") @db.VarChar(100)
|
||||
lastName String? @map("last_name") @db.VarChar(100)
|
||||
firstNameEn String? @map("first_name_en") @db.VarChar(100)
|
||||
lastNameEn String? @map("last_name_en") @db.VarChar(100)
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
|
||||
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
profile Json @default("{}")
|
||||
firstNameEn String? @map("first_name_en") @db.VarChar(100)
|
||||
lastNameEn String? @map("last_name_en") @db.VarChar(100)
|
||||
oldId BigInt? @map("old_id")
|
||||
addresses Address[]
|
||||
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||
businessCustomers BusinessCustomer[]
|
||||
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
|
||||
businessUsers BusinessUser[] @relation("BusinessMember")
|
||||
@@ -35,13 +36,13 @@ model User {
|
||||
mediaUploaded Media[]
|
||||
ordersCreated Order[] @relation("OrderCreator")
|
||||
orders Order[] @relation("OrderCustomer")
|
||||
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
||||
shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator")
|
||||
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
||||
transactionsCreated Transaction[] @relation("TransactionCreator")
|
||||
transactions Transaction[] @relation("TransactionCustomer")
|
||||
userProducts UserProduct[]
|
||||
userRoles UserRole[]
|
||||
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
||||
|
||||
@@index([cellNumber], map: "idx_users_cell_number")
|
||||
@@map("users")
|
||||
@@ -98,6 +99,8 @@ model Business {
|
||||
storeItems StoreItem[]
|
||||
storeSpecials StoreSpecial[]
|
||||
transactions Transaction[]
|
||||
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||
userProducts UserProduct[]
|
||||
website_brand_groups website_brand_groups[]
|
||||
website_category_groups website_category_groups[]
|
||||
website_sliders website_sliders[]
|
||||
@@ -280,6 +283,7 @@ model Media {
|
||||
attachments MediaAttachment[]
|
||||
portfolios portfolios[]
|
||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||
website_slider_slides website_slider_slides[]
|
||||
|
||||
@@index([businessId], map: "idx_media_business_id")
|
||||
@@ -385,6 +389,7 @@ model Brand {
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
|
||||
products Product[]
|
||||
userProducts UserProduct[]
|
||||
website_brand_group_items website_brand_group_items[]
|
||||
|
||||
@@unique([businessId, slug], map: "brands_business_slug_unique")
|
||||
@@ -489,6 +494,7 @@ model CategoryTechnicalFormField {
|
||||
options CategoryTechnicalFormFieldOption[]
|
||||
form CategoryTechnicalForm @relation(fields: [formId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
values ProductTechnicalFieldValue[]
|
||||
userProductFieldValues UserProductTechnicalFieldValue[]
|
||||
|
||||
@@unique([formId, fieldKey], map: "category_technical_form_fields_unique_key")
|
||||
@@index([formId], map: "idx_category_technical_form_fields_form_id")
|
||||
@@ -505,6 +511,8 @@ model CategoryTechnicalFormFieldOption {
|
||||
field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
multiSelectValues ProductTechnicalFieldValueOption[]
|
||||
selectedValues ProductTechnicalFieldValue[]
|
||||
userProductMultiSelectValues UserProductTechnicalFieldValueOption[]
|
||||
userProductSelectedValues UserProductTechnicalFieldValue[]
|
||||
|
||||
@@unique([fieldId, value], map: "category_technical_form_field_options_unique_value")
|
||||
@@index([fieldId], map: "idx_category_technical_form_field_options_field_id")
|
||||
@@ -634,10 +642,7 @@ model blogs {
|
||||
model portfolios {
|
||||
id BigInt @id @default(autoincrement())
|
||||
business_id BigInt
|
||||
author_id BigInt?
|
||||
title String @db.VarChar(255)
|
||||
title_fa String? @db.VarChar(255)
|
||||
title_en String? @db.VarChar(255)
|
||||
slug String @db.VarChar(255)
|
||||
description String?
|
||||
content Json @default("{}")
|
||||
@@ -651,7 +656,10 @@ model portfolios {
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
old_id BigInt?
|
||||
author User? @relation("PortfolioAuthor", fields: [author_id], references: [id], onUpdate: NoAction, onDelete: SetNull)
|
||||
title_fa String? @db.VarChar(255)
|
||||
title_en String? @db.VarChar(255)
|
||||
author_id BigInt?
|
||||
author User? @relation("PortfolioAuthor", fields: [author_id], references: [id], onUpdate: NoAction)
|
||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
|
||||
|
||||
@@ -697,6 +705,9 @@ model City {
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
children City[] @relation("CityTree")
|
||||
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
||||
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
||||
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
||||
|
||||
@@index([level], map: "idx_cities_level")
|
||||
@@index([level, parentId, sortOrder], map: "idx_cities_level_parent")
|
||||
@@ -959,11 +970,11 @@ model StoreSpecial {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
title String @db.VarChar(255)
|
||||
key String @db.VarChar(100)
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
isActive Boolean @default(true) @map("is_active")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
key String @db.VarChar(100)
|
||||
items StoreSpecialItem[]
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@ -989,11 +1000,11 @@ model website_brand_groups {
|
||||
id BigInt @id @default(autoincrement())
|
||||
business_id BigInt
|
||||
title String @db.VarChar(255)
|
||||
key String @db.VarChar(100)
|
||||
sort_order Int @default(0)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
key String @db.VarChar(100)
|
||||
website_brand_group_items website_brand_group_items[]
|
||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@ -1018,11 +1029,11 @@ model website_category_groups {
|
||||
id BigInt @id @default(autoincrement())
|
||||
business_id BigInt
|
||||
title String @db.VarChar(255)
|
||||
key String @db.VarChar(100)
|
||||
sort_order Int @default(0)
|
||||
is_active Boolean @default(true)
|
||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||
key String @db.VarChar(100)
|
||||
website_category_group_items website_category_group_items[]
|
||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@ -1230,6 +1241,88 @@ model InvoiceAccount {
|
||||
@@map("invoice_accounts")
|
||||
}
|
||||
|
||||
model UserProductTechnicalFieldValueOption {
|
||||
fieldValueId BigInt @map("field_value_id")
|
||||
optionId BigInt @map("option_id")
|
||||
fieldValue UserProductTechnicalFieldValue @relation(fields: [fieldValueId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
option CategoryTechnicalFormFieldOption @relation(fields: [optionId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@id([fieldValueId, optionId])
|
||||
@@index([optionId], map: "idx_user_product_technical_field_value_options_option_id")
|
||||
@@map("user_product_technical_field_value_options")
|
||||
}
|
||||
|
||||
model UserProductTechnicalFieldValue {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
userProductId BigInt @map("user_product_id")
|
||||
fieldId BigInt @map("field_id")
|
||||
textValue String? @map("text_value")
|
||||
optionId BigInt? @map("option_id")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
selectedOptions UserProductTechnicalFieldValueOption[]
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
option CategoryTechnicalFormFieldOption? @relation(fields: [optionId], references: [id], onUpdate: NoAction)
|
||||
userProduct UserProduct @relation(fields: [userProductId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@unique([userProductId, fieldId], map: "user_product_technical_field_values_unique")
|
||||
@@index([businessId], map: "idx_user_product_technical_field_values_business_id")
|
||||
@@index([userProductId], map: "idx_user_product_technical_field_values_user_product_id")
|
||||
@@map("user_product_technical_field_values")
|
||||
}
|
||||
|
||||
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
|
||||
model UserProduct {
|
||||
id BigInt @id @default(autoincrement())
|
||||
businessId BigInt @map("business_id")
|
||||
userId BigInt @map("user_id")
|
||||
title String @db.VarChar(255)
|
||||
slug String @db.VarChar(255)
|
||||
description String?
|
||||
content Json @default("{}")
|
||||
price Decimal? @db.Decimal(12, 2)
|
||||
compareAtPrice Decimal? @map("compare_at_price") @db.Decimal(12, 2)
|
||||
sku String? @db.VarChar(100)
|
||||
stockQuantity Int? @map("stock_quantity")
|
||||
status ContentStatus @default(draft)
|
||||
featuredMediaId BigInt? @map("featured_media_id")
|
||||
brandId BigInt? @map("brand_id")
|
||||
countryId BigInt @map("country_id")
|
||||
cityId BigInt @map("city_id")
|
||||
districtId BigInt? @map("district_id")
|
||||
sortOrder Int @default(0) @map("sort_order")
|
||||
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
|
||||
metadata Json @default("{}")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
priceCurrency String @default("IRT") @map("price_currency") @db.VarChar(8)
|
||||
deliveryNote String? @map("delivery_note")
|
||||
condition UserProductCondition @default(new)
|
||||
technicalNotes String? @map("technical_notes")
|
||||
technicalFieldValues UserProductTechnicalFieldValue[]
|
||||
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
|
||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
city City @relation("UserProductCity", fields: [cityId], references: [id], onUpdate: NoAction)
|
||||
country City @relation("UserProductCountry", fields: [countryId], references: [id], onUpdate: NoAction)
|
||||
district City? @relation("UserProductDistrict", fields: [districtId], references: [id], onUpdate: NoAction)
|
||||
featuredMedia Media? @relation("UserProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||
|
||||
@@unique([businessId, slug], map: "user_products_business_slug_unique")
|
||||
@@index([brandId], map: "idx_user_products_brand_id")
|
||||
@@index([businessId], map: "idx_user_products_business_id")
|
||||
@@index([businessId, publishedAt(sort: Desc)], map: "idx_user_products_business_published")
|
||||
@@index([businessId, status], map: "idx_user_products_business_status")
|
||||
@@index([businessId, userId], map: "idx_user_products_business_user")
|
||||
@@index([cityId], map: "idx_user_products_city_id")
|
||||
@@index([countryId], map: "idx_user_products_country_id")
|
||||
@@index([districtId], map: "idx_user_products_district_id")
|
||||
@@index([userId], map: "idx_user_products_user_id")
|
||||
@@map("user_products")
|
||||
}
|
||||
|
||||
enum MediaType {
|
||||
image
|
||||
video
|
||||
@@ -1242,6 +1335,7 @@ enum MediaEntityType {
|
||||
blog
|
||||
portfolio
|
||||
customer
|
||||
user_product
|
||||
|
||||
@@map("media_entity_type")
|
||||
}
|
||||
@@ -1258,6 +1352,7 @@ enum ContentStatus {
|
||||
draft
|
||||
published
|
||||
archived
|
||||
rejected
|
||||
|
||||
@@map("content_status")
|
||||
}
|
||||
@@ -1283,6 +1378,7 @@ enum CityLevel {
|
||||
country
|
||||
province
|
||||
city
|
||||
district
|
||||
|
||||
@@map("city_level")
|
||||
}
|
||||
@@ -1339,3 +1435,12 @@ enum InvoiceStatus {
|
||||
|
||||
@@map("invoice_status")
|
||||
}
|
||||
|
||||
enum UserProductCondition {
|
||||
new
|
||||
stock
|
||||
needs_repair
|
||||
scrap
|
||||
|
||||
@@map("user_product_condition")
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ import { CustomersModule } from './customers/customers.module';
|
||||
import { ShoppingCardsModule } from './shopping-cards/shopping-cards.module';
|
||||
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
|
||||
import { FavoritesModule } from './favorites/favorites.module';
|
||||
import { UserProductsModule } from './user-products/user-products.module';
|
||||
import { BrandsModule } from './brands/brands.module';
|
||||
import { WebsiteModule } from './website/website.module';
|
||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||
@@ -69,6 +70,7 @@ import { PublicSmsModule } from './public-sms/public-sms.module';
|
||||
ShoppingCardsModule,
|
||||
ContactSubmissionsModule,
|
||||
FavoritesModule,
|
||||
UserProductsModule,
|
||||
BrandsModule,
|
||||
WebsiteModule,
|
||||
WebsiteDocsModule,
|
||||
|
||||
@@ -25,12 +25,14 @@ import { normalizeBusinessPrimaryColorId } from '../business-settings/business-p
|
||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||
import {
|
||||
normalizeDashboardLocale,
|
||||
normalizeDashboardThemeMode,
|
||||
normalizeEnabledBusinessModules,
|
||||
normalizeHomeCharts,
|
||||
} from '../business-settings/business-settings.util';
|
||||
import type {
|
||||
BusinessModuleId,
|
||||
DashboardLocale,
|
||||
DashboardThemeMode,
|
||||
HomeChartId,
|
||||
} from '../business-settings/business-settings.types';
|
||||
import {
|
||||
@@ -64,6 +66,7 @@ type BusinessRow = {
|
||||
ownerCellNumber: string | null;
|
||||
primaryColor: string | null;
|
||||
defaultLocale: string | null;
|
||||
themeMode: string | null;
|
||||
enabledModules: unknown;
|
||||
homeCharts: unknown;
|
||||
};
|
||||
@@ -145,6 +148,7 @@ export class BusinessAdminService {
|
||||
own."ownerCellNumber" AS "ownerCellNumber",
|
||||
b.settings->'branding'->>'primaryColor' AS "primaryColor",
|
||||
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
||||
b.settings->'branding'->>'themeMode' AS "themeMode",
|
||||
b.settings->'modules'->'enabled' AS "enabledModules",
|
||||
b.settings->'modules'->'charts' AS "homeCharts"
|
||||
FROM businesses b
|
||||
@@ -201,6 +205,9 @@ export class BusinessAdminService {
|
||||
defaultLocale: normalizeDashboardLocale(
|
||||
item.defaultLocale,
|
||||
) as DashboardLocale,
|
||||
themeMode: normalizeDashboardThemeMode(
|
||||
item.themeMode,
|
||||
) as DashboardThemeMode,
|
||||
enabledModules,
|
||||
moduleCount: enabledModules.length,
|
||||
homeCharts,
|
||||
|
||||
@@ -78,6 +78,7 @@ export class BusinessSettingsService {
|
||||
),
|
||||
defaultLocale:
|
||||
dto.branding.defaultLocale ?? current.branding.defaultLocale,
|
||||
themeMode: dto.branding.themeMode ?? current.branding.themeMode,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -5,10 +5,16 @@ export type DashboardLocale = 'en' | 'fa';
|
||||
|
||||
export const DEFAULT_BUSINESS_DASHBOARD_LOCALE: DashboardLocale = 'fa';
|
||||
|
||||
export type DashboardThemeMode = 'light' | 'dark';
|
||||
|
||||
export const DEFAULT_BUSINESS_THEME_MODE: DashboardThemeMode = 'light';
|
||||
|
||||
export type BrandingSettings = {
|
||||
primaryColor: BusinessPrimaryColorId;
|
||||
/** Default UI language for business + customer dashboards. */
|
||||
defaultLocale: DashboardLocale;
|
||||
/** Dashboard surface theme (neutral light/dark). Independent of primary brand color. */
|
||||
themeMode: DashboardThemeMode;
|
||||
};
|
||||
|
||||
export type DashboardCommentsSettings = {
|
||||
@@ -38,8 +44,8 @@ export type StoreSettings = {
|
||||
orderProcessSteps: OrderProcessStep[];
|
||||
};
|
||||
|
||||
/** Optional CMS modules a business can have. Always-on areas (customers, website, etc.) are not listed. */
|
||||
export const BUSINESS_MODULE_IDS = [
|
||||
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
|
||||
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||
'products',
|
||||
'store',
|
||||
'portfolio',
|
||||
@@ -48,6 +54,18 @@ export const BUSINESS_MODULE_IDS = [
|
||||
'videos',
|
||||
] as const;
|
||||
|
||||
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
|
||||
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const;
|
||||
|
||||
/** All optional modules stored in `settings.modules.enabled`. */
|
||||
export const BUSINESS_MODULE_IDS = [
|
||||
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
...CUSTOMER_MODULE_IDS,
|
||||
] as const;
|
||||
|
||||
export type BusinessDashboardModuleId =
|
||||
(typeof BUSINESS_DASHBOARD_MODULE_IDS)[number];
|
||||
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number];
|
||||
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number];
|
||||
|
||||
/** Home dashboard chart types (super-admin selectable). */
|
||||
@@ -102,9 +120,12 @@ export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
|
||||
},
|
||||
];
|
||||
|
||||
/** Existing tenants without `settings.modules` keep every module enabled. */
|
||||
/**
|
||||
* Existing tenants without `settings.modules` keep every business-dashboard module
|
||||
* enabled. Customer modules stay opt-in.
|
||||
*/
|
||||
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||
...BUSINESS_MODULE_IDS,
|
||||
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||
];
|
||||
|
||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||
@@ -116,6 +137,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
||||
branding: {
|
||||
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
||||
themeMode: DEFAULT_BUSINESS_THEME_MODE,
|
||||
},
|
||||
dashboard: {
|
||||
comments: { autoApprove: false },
|
||||
|
||||
@@ -8,11 +8,13 @@ import {
|
||||
BusinessSettings,
|
||||
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
||||
DEFAULT_BUSINESS_SETTINGS,
|
||||
DEFAULT_BUSINESS_THEME_MODE,
|
||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||
DEFAULT_HOME_CHARTS,
|
||||
DEFAULT_ORDER_PROCESS_STEPS,
|
||||
HOME_CHART_IDS,
|
||||
type DashboardLocale,
|
||||
type DashboardThemeMode,
|
||||
type HomeChartId,
|
||||
OrderProcessStep,
|
||||
} from './business-settings.types';
|
||||
@@ -68,6 +70,12 @@ export function normalizeDashboardLocale(value: unknown): DashboardLocale {
|
||||
return value === 'en' || value === 'fa' ? value : DEFAULT_BUSINESS_DASHBOARD_LOCALE;
|
||||
}
|
||||
|
||||
export function normalizeDashboardThemeMode(value: unknown): DashboardThemeMode {
|
||||
return value === 'dark' || value === 'light'
|
||||
? value
|
||||
: DEFAULT_BUSINESS_THEME_MODE;
|
||||
}
|
||||
|
||||
function readBoolean(value: unknown, fallback: boolean) {
|
||||
return typeof value === 'boolean' ? value : fallback;
|
||||
}
|
||||
@@ -122,6 +130,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
||||
branding: {
|
||||
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
|
||||
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
|
||||
themeMode: normalizeDashboardThemeMode(branding.themeMode),
|
||||
},
|
||||
dashboard: {
|
||||
comments: {
|
||||
@@ -166,6 +175,7 @@ export function mergeBusinessSettings(
|
||||
patch.branding?.primaryColor ?? current.branding.primaryColor,
|
||||
defaultLocale:
|
||||
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
|
||||
themeMode: patch.branding?.themeMode ?? current.branding.themeMode,
|
||||
},
|
||||
dashboard: {
|
||||
comments: {
|
||||
|
||||
@@ -27,6 +27,11 @@ class BrandingSettingsDto {
|
||||
@IsString()
|
||||
@IsIn(['en', 'fa'])
|
||||
defaultLocale?: 'en' | 'fa';
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@IsIn(['light', 'dark'])
|
||||
themeMode?: 'light' | 'dark';
|
||||
}
|
||||
|
||||
class DashboardCommentsSettingsDto {
|
||||
|
||||
@@ -14,13 +14,43 @@ import {
|
||||
} from './dto/category-technical-form.dto';
|
||||
|
||||
function slugifyKey(value: string): string {
|
||||
return (
|
||||
value
|
||||
const ascii = value
|
||||
.toLowerCase()
|
||||
.trim()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'field'
|
||||
);
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
if (ascii) {
|
||||
return ascii;
|
||||
}
|
||||
|
||||
// Keep letters/numbers from any script (e.g. Farsi labels) when ASCII strip is empty
|
||||
const unicode = value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.normalize('NFKC')
|
||||
.replace(/\s+/g, '-')
|
||||
.replace(/[^\p{L}\p{N}-]+/gu, '')
|
||||
.replace(/-+/g, '-')
|
||||
.replace(/^-+|-+$/g, '');
|
||||
|
||||
return unicode || 'field';
|
||||
}
|
||||
|
||||
function uniqueSlug(base: string, used: Set<string>): string {
|
||||
let value = base || 'field';
|
||||
if (!used.has(value)) {
|
||||
used.add(value);
|
||||
return value;
|
||||
}
|
||||
|
||||
let suffix = 2;
|
||||
while (used.has(`${value}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
const next = `${value}-${suffix}`;
|
||||
used.add(next);
|
||||
return next;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
@@ -88,15 +118,7 @@ export class CategoryTechnicalFormService {
|
||||
const usedKeys = new Set<string>();
|
||||
|
||||
for (const [index, field] of dto.fields.entries()) {
|
||||
let fieldKey = slugifyKey(field.label);
|
||||
if (usedKeys.has(fieldKey)) {
|
||||
let suffix = 2;
|
||||
while (usedKeys.has(`${fieldKey}-${suffix}`)) {
|
||||
suffix += 1;
|
||||
}
|
||||
fieldKey = `${fieldKey}-${suffix}`;
|
||||
}
|
||||
usedKeys.add(fieldKey);
|
||||
const fieldKey = uniqueSlug(slugifyKey(field.label), usedKeys);
|
||||
|
||||
const createdField = await tx.categoryTechnicalFormField.create({
|
||||
data: {
|
||||
@@ -113,12 +135,13 @@ export class CategoryTechnicalFormService {
|
||||
const uniqueOptions = [
|
||||
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
|
||||
];
|
||||
const usedValues = new Set<string>();
|
||||
|
||||
await tx.categoryTechnicalFormFieldOption.createMany({
|
||||
data: uniqueOptions.map((label, optionIndex) => ({
|
||||
fieldId: createdField.id,
|
||||
label,
|
||||
value: slugifyKey(label) || `option-${optionIndex + 1}`,
|
||||
value: uniqueSlug(slugifyKey(label), usedValues),
|
||||
sortOrder: optionIndex,
|
||||
})),
|
||||
});
|
||||
|
||||
@@ -26,25 +26,51 @@ export class CitiesService {
|
||||
...(query.level ? { level: query.level } : {}),
|
||||
};
|
||||
|
||||
if (query.parentId) {
|
||||
where.parentId = BigInt(query.parentId);
|
||||
} else if (query.parentSlug) {
|
||||
const parent = await this.prisma.city.findFirst({
|
||||
where: { slug: query.parentSlug, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
let parent:
|
||||
| { id: bigint; level: CityLevel }
|
||||
| null = null;
|
||||
|
||||
if (query.parentId) {
|
||||
parent = await this.prisma.city.findFirst({
|
||||
where: { id: BigInt(query.parentId), isActive: true },
|
||||
select: { id: true, level: true },
|
||||
});
|
||||
if (!parent) {
|
||||
return { items: [] };
|
||||
}
|
||||
} else if (query.parentSlug) {
|
||||
parent = await this.prisma.city.findFirst({
|
||||
where: { slug: query.parentSlug, isActive: true },
|
||||
select: { id: true, level: true },
|
||||
});
|
||||
if (!parent) {
|
||||
return { items: [] };
|
||||
}
|
||||
|
||||
where.parentId = parent.id;
|
||||
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
|
||||
throw new BadRequestException('parentId or parentSlug is required for this level');
|
||||
} else {
|
||||
where.level = CityLevel.country;
|
||||
}
|
||||
|
||||
if (parent) {
|
||||
// Cities under a country: direct children + cities under that country's provinces.
|
||||
if (query.level === CityLevel.city && parent.level === CityLevel.country) {
|
||||
const provinces = await this.prisma.city.findMany({
|
||||
where: {
|
||||
parentId: parent.id,
|
||||
level: CityLevel.province,
|
||||
isActive: true,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
where.parentId = {
|
||||
in: [parent.id, ...provinces.map((item) => item.id)],
|
||||
};
|
||||
} else {
|
||||
where.parentId = parent.id;
|
||||
}
|
||||
}
|
||||
|
||||
const items = await this.prisma.city.findMany({
|
||||
where,
|
||||
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
|
||||
|
||||
@@ -7,5 +7,6 @@ import { MediaService } from './media.service';
|
||||
imports: [AuthModule],
|
||||
controllers: [MediaController],
|
||||
providers: [MediaService],
|
||||
exports: [MediaService],
|
||||
})
|
||||
export class MediaModule {}
|
||||
|
||||
@@ -99,6 +99,38 @@ export class MediaService {
|
||||
return { items };
|
||||
}
|
||||
|
||||
/**
|
||||
* Customer-dashboard uploads for user-product images.
|
||||
* Requires business-customer membership instead of media.create.
|
||||
*/
|
||||
async uploadManyForCustomer(
|
||||
businessIdRaw: string,
|
||||
files: Express.Multer.File[],
|
||||
actor: AuthUser,
|
||||
) {
|
||||
const businessId = BigInt(businessIdRaw);
|
||||
await this.assertBusinessCustomer(businessId, actor.id);
|
||||
|
||||
if (!files.length) {
|
||||
throw new BadRequestException('At least one file is required');
|
||||
}
|
||||
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: businessId },
|
||||
});
|
||||
|
||||
if (!business?.isActive) {
|
||||
throw new NotFoundException('Business not found');
|
||||
}
|
||||
|
||||
const items = [];
|
||||
for (const file of files) {
|
||||
items.push(await this.uploadOne(businessId, file, actor.id));
|
||||
}
|
||||
|
||||
return { items };
|
||||
}
|
||||
|
||||
async update(
|
||||
businessIdRaw: string,
|
||||
mediaIdRaw: string,
|
||||
@@ -371,4 +403,20 @@ export class MediaService {
|
||||
throw new ForbiddenException('You cannot delete media for this business');
|
||||
}
|
||||
}
|
||||
|
||||
private async assertBusinessCustomer(businessId: bigint, userId: bigint) {
|
||||
if (await this.permissions.isSuperAdmin(userId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const membership = await this.prisma.businessCustomer.findUnique({
|
||||
where: {
|
||||
businessId_userId: { businessId, userId },
|
||||
},
|
||||
});
|
||||
|
||||
if (!membership) {
|
||||
throw new ForbiddenException('You are not a customer of this business');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,7 @@ export class TenantService {
|
||||
domain: normalizedHost,
|
||||
primaryColor: settings.branding.primaryColor,
|
||||
defaultLocale: settings.branding.defaultLocale,
|
||||
themeMode: settings.branding.themeMode,
|
||||
enabledModules: settings.modules.enabled,
|
||||
homeCharts: settings.modules.charts,
|
||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayUnique,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsEnum,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsNumber,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Min,
|
||||
MinLength,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
import { ContentStatus, UserProductCondition } from '@prisma/client';
|
||||
|
||||
export const USER_PRODUCT_PRICE_CURRENCIES = ['IRT', 'USD', 'EUR', 'AED'] as const;
|
||||
export type UserProductPriceCurrency =
|
||||
(typeof USER_PRODUCT_PRICE_CURRENCIES)[number];
|
||||
|
||||
export class ListMyUserProductsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
}
|
||||
|
||||
export class ListAdminUserProductsDto extends ListMyUserProductsDto {
|
||||
@IsOptional()
|
||||
@IsEnum(ContentStatus)
|
||||
status?: ContentStatus;
|
||||
}
|
||||
|
||||
export class ListPublicUserProductsDto {
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
pageSize?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
name?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
categoryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cityId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
countryId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsEnum(UserProductCondition)
|
||||
condition?: UserProductCondition;
|
||||
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => value === 'true' || value === true)
|
||||
@IsBoolean()
|
||||
promoted?: boolean;
|
||||
}
|
||||
|
||||
export class UserProductTechnicalValueDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
fieldId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
textValue?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
optionId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayUnique()
|
||||
@IsString({ each: true })
|
||||
optionIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateUserProductDto {
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
titleFa!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
titleEn?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
description?: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
categoryId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsNumber({ maxDecimalPlaces: 2 })
|
||||
@Min(0)
|
||||
price?: number;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(USER_PRODUCT_PRICE_CURRENCIES)
|
||||
priceCurrency?: UserProductPriceCurrency;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
priceByExpert?: boolean;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
countryId!: string;
|
||||
|
||||
@IsString()
|
||||
@MinLength(1)
|
||||
cityId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
deliveryNote?: string;
|
||||
|
||||
@IsEnum(UserProductCondition)
|
||||
condition!: UserProductCondition;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
technicalNotes?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => UserProductTechnicalValueDto)
|
||||
technicalValues?: UserProductTechnicalValueDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
featuredMediaId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
galleryMediaIds?: string[];
|
||||
}
|
||||
|
||||
export class UpdateUserProductDto extends CreateUserProductDto {}
|
||||
|
||||
export class UpdateUserProductStatusDto {
|
||||
@IsEnum(ContentStatus)
|
||||
status!: ContentStatus;
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { RequireBusinessPermission } from '../auth/decorators/require-business-permission.decorator';
|
||||
import { BusinessPermissionGuard } from '../auth/guards/business-permission.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
CreateUserProductDto,
|
||||
ListAdminUserProductsDto,
|
||||
UpdateUserProductDto,
|
||||
UpdateUserProductStatusDto,
|
||||
} from './dto/user-product.dto';
|
||||
import { UserProductsService } from './user-products.service';
|
||||
|
||||
@Controller('businesses/:businessId/user-products')
|
||||
@UseGuards(JwtAuthGuard, BusinessPermissionGuard)
|
||||
export class UserProductsAdminController {
|
||||
constructor(private readonly service: UserProductsService) {}
|
||||
|
||||
@Get('categories')
|
||||
@RequireBusinessPermission('user_products.read')
|
||||
listCategories(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listCategoriesForAdmin(businessId, user);
|
||||
}
|
||||
|
||||
@Get('categories/:categoryId/technical-form')
|
||||
@RequireBusinessPermission('user_products.read')
|
||||
getCategoryTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getCategoryTechnicalFormForAdmin(
|
||||
businessId,
|
||||
categoryId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Get()
|
||||
@RequireBusinessPermission('user_products.read')
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListAdminUserProductsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminList(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@RequireBusinessPermission('user_products.create')
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateUserProductDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminCreate(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Get(':productId')
|
||||
@RequireBusinessPermission('user_products.read')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminGetOne(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Patch(':productId')
|
||||
@RequireBusinessPermission('user_products.update')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: UpdateUserProductDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminUpdate(businessId, productId, dto, user);
|
||||
}
|
||||
|
||||
@Patch(':productId/status')
|
||||
@RequireBusinessPermission('user_products.update')
|
||||
updateStatus(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: UpdateUserProductStatusDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminUpdateStatus(businessId, productId, dto, user);
|
||||
}
|
||||
|
||||
@Post(':productId/promote')
|
||||
@RequireBusinessPermission('user_products.update')
|
||||
promote(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminPromote(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Delete(':productId')
|
||||
@RequireBusinessPermission('user_products.delete')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.adminRemove(businessId, productId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { AuthUser } from '../auth/auth.types';
|
||||
import { CurrentUser } from '../auth/decorators/current-user.decorator';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import {
|
||||
CreateUserProductDto,
|
||||
ListMyUserProductsDto,
|
||||
UpdateUserProductDto,
|
||||
} from './dto/user-product.dto';
|
||||
import { UserProductsService } from './user-products.service';
|
||||
|
||||
@Controller('businesses/:businessId/my-user-products')
|
||||
@UseGuards(JwtAuthGuard)
|
||||
export class UserProductsController {
|
||||
constructor(private readonly service: UserProductsService) {}
|
||||
|
||||
@Get('categories')
|
||||
listCategories(
|
||||
@Param('businessId') businessId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.listCategories(businessId, user);
|
||||
}
|
||||
|
||||
@Get('categories/:categoryId/technical-form')
|
||||
getCategoryTechnicalForm(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('categoryId') categoryId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getCategoryTechnicalForm(
|
||||
businessId,
|
||||
categoryId,
|
||||
user,
|
||||
);
|
||||
}
|
||||
|
||||
@Post('media')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 10, {
|
||||
storage: memoryStorage(),
|
||||
}),
|
||||
)
|
||||
uploadMedia(
|
||||
@Param('businessId') businessId: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.uploadMedia(businessId, files ?? [], user);
|
||||
}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Param('businessId') businessId: string,
|
||||
@Query() query: ListMyUserProductsDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.list(businessId, query, user);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Param('businessId') businessId: string,
|
||||
@Body() dto: CreateUserProductDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.create(businessId, dto, user);
|
||||
}
|
||||
|
||||
@Get(':productId')
|
||||
getOne(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.getOne(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Patch(':productId')
|
||||
update(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@Body() dto: UpdateUserProductDto,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.update(businessId, productId, dto, user);
|
||||
}
|
||||
|
||||
@Post(':productId/promote')
|
||||
promote(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.promote(businessId, productId, user);
|
||||
}
|
||||
|
||||
@Delete(':productId')
|
||||
remove(
|
||||
@Param('businessId') businessId: string,
|
||||
@Param('productId') productId: string,
|
||||
@CurrentUser() user: AuthUser,
|
||||
) {
|
||||
return this.service.remove(businessId, productId, user);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { AuthModule } from '../auth/auth.module';
|
||||
import { CategoriesModule } from '../categories/categories.module';
|
||||
import { MediaModule } from '../media/media.module';
|
||||
import { TenantModule } from '../tenant/tenant.module';
|
||||
import { UserProductsAdminController } from './user-products.admin.controller';
|
||||
import { UserProductsController } from './user-products.controller';
|
||||
import { PublicUserProductsController } from './user-products.public.controller';
|
||||
import { UserProductsService } from './user-products.service';
|
||||
|
||||
@Module({
|
||||
imports: [AuthModule, CategoriesModule, MediaModule, TenantModule],
|
||||
controllers: [
|
||||
UserProductsController,
|
||||
UserProductsAdminController,
|
||||
PublicUserProductsController,
|
||||
],
|
||||
providers: [UserProductsService],
|
||||
})
|
||||
export class UserProductsModule {}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Controller, Get, Param, Query } from '@nestjs/common';
|
||||
import { ListPublicUserProductsDto } from './dto/user-product.dto';
|
||||
import { UserProductsService } from './user-products.service';
|
||||
|
||||
@Controller('tenants/:host/user-products')
|
||||
export class PublicUserProductsController {
|
||||
constructor(private readonly service: UserProductsService) {}
|
||||
|
||||
@Get()
|
||||
list(
|
||||
@Param('host') host: string,
|
||||
@Query() query: ListPublicUserProductsDto,
|
||||
) {
|
||||
return this.service.listPublic(host, query);
|
||||
}
|
||||
|
||||
@Get(':slug/technical-info')
|
||||
getTechnicalInfo(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicTechnicalInfoBySlug(host, slug);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('host') host: string, @Param('slug') slug: string) {
|
||||
return this.service.getPublicBySlug(host, slug);
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -31,10 +31,17 @@ You are building a **Meshkee business website (storefront)**. You must use the M
|
||||
### Typical bootstrap sequence
|
||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
|
||||
3. Catalog: categories, products, store-items
|
||||
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
|
||||
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
|
||||
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
|
||||
|
||||
### User products (customer listings)
|
||||
Public marketplace listings owned by customers — not catalog `products`.
|
||||
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
|
||||
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery
|
||||
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
|
||||
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
|
||||
|
||||
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||
|
||||
---
|
||||
|
||||
@@ -33,6 +33,14 @@
|
||||
"key": "productSlug",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "userProductId",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "userProductSlug",
|
||||
"value": ""
|
||||
},
|
||||
{
|
||||
"key": "blogId",
|
||||
"value": ""
|
||||
@@ -1101,6 +1109,133 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "User Products",
|
||||
"item": [
|
||||
{
|
||||
"name": "List published user products",
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"if (pm.response.code === 200) {",
|
||||
" const json = pm.response.json();",
|
||||
" if (json.items?.[0]?.id) pm.collectionVariables.set('userProductId', json.items[0].id);",
|
||||
" if (json.items?.[0]?.slug) pm.collectionVariables.set('userProductSlug', json.items[0].slug);",
|
||||
"}"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?page=1&pageSize=12",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"tenants",
|
||||
"{{domain}}",
|
||||
"user-products"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "pageSize",
|
||||
"value": "12"
|
||||
},
|
||||
{
|
||||
"key": "name",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "q",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "categoryId",
|
||||
"value": "{{categoryId}}",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "cityId",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "countryId",
|
||||
"value": "",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "condition",
|
||||
"value": "new",
|
||||
"disabled": true
|
||||
},
|
||||
{
|
||||
"key": "promoted",
|
||||
"value": "true",
|
||||
"disabled": true
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Search user products",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": {
|
||||
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?q=boiler&page=1&pageSize=12",
|
||||
"host": [
|
||||
"{{baseUrl}}"
|
||||
],
|
||||
"path": [
|
||||
"tenants",
|
||||
"{{domain}}",
|
||||
"user-products"
|
||||
],
|
||||
"query": [
|
||||
{
|
||||
"key": "q",
|
||||
"value": "boiler"
|
||||
},
|
||||
{
|
||||
"key": "page",
|
||||
"value": "1"
|
||||
},
|
||||
{
|
||||
"key": "pageSize",
|
||||
"value": "12"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get user product by slug",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "Get user product technical info by slug",
|
||||
"request": {
|
||||
"method": "GET",
|
||||
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Store Items",
|
||||
"item": [
|
||||
|
||||
@@ -88,10 +88,17 @@
|
||||
<ol>
|
||||
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
|
||||
<li><code>GET /tenants/{domain}</code> → <code>businessId</code>.</li>
|
||||
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
|
||||
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
|
||||
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
|
||||
</ol>
|
||||
|
||||
<h2>User products (customer listings)</h2>
|
||||
<p>
|
||||
Marketplace-style stock listings created by customers. Public read-only under
|
||||
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
|
||||
See OpenAPI tag <strong>User Products</strong>.
|
||||
</p>
|
||||
|
||||
<h2>For a new website AI / designer</h2>
|
||||
<ol>
|
||||
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
{ "name": "Homepage" },
|
||||
{ "name": "Categories" },
|
||||
{ "name": "Products" },
|
||||
{ "name": "User Products" },
|
||||
{ "name": "Store" },
|
||||
{ "name": "Blogs" },
|
||||
{ "name": "Portfolios" },
|
||||
@@ -210,6 +211,66 @@
|
||||
"responses": { "200": { "description": "{ form, values }" } }
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/user-products": {
|
||||
"get": {
|
||||
"tags": ["User Products"],
|
||||
"summary": "List published customer / stock listings",
|
||||
"description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/domain" },
|
||||
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
|
||||
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
|
||||
{ "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } },
|
||||
{ "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } },
|
||||
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "cityId", "in": "query", "schema": { "type": "string" } },
|
||||
{ "name": "countryId", "in": "query", "schema": { "type": "string" } },
|
||||
{
|
||||
"name": "condition",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": ["new", "stock", "needs_repair", "scrap"]
|
||||
}
|
||||
},
|
||||
{ "name": "promoted", "in": "query", "schema": { "type": "boolean" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/user-products/{slug}": {
|
||||
"get": {
|
||||
"tags": ["User Products"],
|
||||
"summary": "User product details by slug",
|
||||
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/domain" },
|
||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug"
|
||||
},
|
||||
"404": { "description": "Not found or not published" }
|
||||
}
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/user-products/{slug}/technical-info": {
|
||||
"get": {
|
||||
"tags": ["User Products"],
|
||||
"summary": "User product technical form + values",
|
||||
"description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).",
|
||||
"parameters": [
|
||||
{ "$ref": "#/components/parameters/domain" },
|
||||
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
|
||||
],
|
||||
"responses": { "200": { "description": "{ form, values }" } }
|
||||
}
|
||||
},
|
||||
"/tenants/{domain}/store-items": {
|
||||
"get": {
|
||||
"tags": ["Store"],
|
||||
|
||||
Reference in New Issue
Block a user