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
|
# Meshkee CMS API — Project Context
|
||||||
|
|
||||||
> Living reference for developers and AI assistants working on this codebase.
|
> 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
|
## What This Project Is
|
||||||
|
|
||||||
@@ -80,6 +80,8 @@ src/
|
|||||||
├── website-docs/ # Public website API docs pack
|
├── website-docs/ # Public website API docs pack
|
||||||
├── invoices/ # Platform invoices + item templates (super-admin; business-ready schema)
|
├── invoices/ # Platform invoices + item templates (super-admin; business-ready schema)
|
||||||
├── public-sms/ # Partner SMS gateway (API key + domain allowlist → Gama)
|
├── 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
|
├── prisma/ # PrismaModule + PrismaService
|
||||||
├── redis/ # Redis client + OTP helpers
|
├── redis/ # Redis client + OTP helpers
|
||||||
└── common/ # Shared interceptors (BigInt serialization)
|
└── 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 |
|
| `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 |
|
| `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 |
|
| `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.
|
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 |
|
| Enum | Values |
|
||||||
|------|--------|
|
|------|--------|
|
||||||
| `MediaEntityType` | `product`, `blog`, `portfolio` |
|
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
|
||||||
| `ContentStatus` | `draft`, `published`, `archived` |
|
| `ContentStatus` | `draft`, `published`, `archived` |
|
||||||
| `VariationType` | `color`, `size`, `custom` |
|
| `VariationType` | `color`, `size`, `custom` |
|
||||||
|
| `CityLevel` | `country`, `province`, `city`, `district` |
|
||||||
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
|
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
|
||||||
| `OrderSource` | `website`, `admin` |
|
| `OrderSource` | `website`, `admin` |
|
||||||
| `InvoiceOwnerScope` | `platform`, `business` |
|
| `InvoiceOwnerScope` | `platform`, `business` |
|
||||||
| `InvoiceStatus` | `draft`, `issued`, `approved`, `paid`, `cancelled` |
|
| `InvoiceStatus` | `draft`, `issued`, `approved`, `paid`, `cancelled` |
|
||||||
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
|
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
|
||||||
|
| `UserProductCondition` | `new`, `stock`, `needs_repair`, `scrap` |
|
||||||
| `MediaType` | `image`, `video` |
|
| `MediaType` | `image`, `video` |
|
||||||
|
|
||||||
### Core relationships
|
### Core relationships
|
||||||
|
|
||||||
```
|
```
|
||||||
Business 1──* Domain
|
Business 1──* Domain
|
||||||
Business 1──* Category (entityType: product|blog|portfolio)
|
Business 1──* Category (entityType: product|blog|portfolio|customer)
|
||||||
Business 1──* Product
|
Business 1──* Product
|
||||||
|
Business 1──* UserProduct (customer stock; no variations; location + technical data)
|
||||||
Business 1──* Media
|
Business 1──* Media
|
||||||
|
|
||||||
Category 1──* CategoryVariation 1──* CategoryVariationOption
|
Category 1──* CategoryVariation 1──* CategoryVariationOption
|
||||||
@@ -194,12 +203,16 @@ CategoryTechnicalFormField 1──* CategoryTechnicalFormFieldOption
|
|||||||
|
|
||||||
Product *──0..1 Category (via CategoryAssignment)
|
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──* ProductVariationValue → CategoryVariationOption (which options this product offers)
|
|
||||||
Product 1──0..1 StoreItem (one shop listing per product)
|
Product 1──0..1 StoreItem (one shop listing per product)
|
||||||
StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo)
|
StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo)
|
||||||
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
|
||||||
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
|
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──* Cart (per customer) 1──* CartItem → ProductVariant
|
||||||
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
|
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
|
||||||
User 1──* Cart, Order (as customer)
|
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/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
|
||||||
| GET/PUT | `/products/:id/technical-info` | Product technical data |
|
| 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)
|
#### Cart (customer — JWT, must be business customer)
|
||||||
|
|
||||||
| Method | Path | Description |
|
| Method | Path | Description |
|
||||||
@@ -583,7 +617,8 @@ See `.env.example` for the full list. Key groups:
|
|||||||
| Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes |
|
| Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes |
|
||||||
| Customer dashboard | Partial | No | Register only | Yes |
|
| Customer dashboard | Partial | No | Register only | Yes |
|
||||||
| Store checkout (cart, orders) | Yes | Yes | Yes | 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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||||
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
|
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).
|
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`
|
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**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
"key": "productSlug",
|
"key": "productSlug",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "userProductId",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "userProductSlug",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "blogId",
|
"key": "blogId",
|
||||||
"value": ""
|
"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",
|
"name": "Store Items",
|
||||||
"item": [
|
"item": [
|
||||||
|
|||||||
@@ -88,10 +88,17 @@
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
|
<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><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>
|
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
|
||||||
</ol>
|
</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>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
<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": "Homepage" },
|
||||||
{ "name": "Categories" },
|
{ "name": "Categories" },
|
||||||
{ "name": "Products" },
|
{ "name": "Products" },
|
||||||
|
{ "name": "User Products" },
|
||||||
{ "name": "Store" },
|
{ "name": "Store" },
|
||||||
{ "name": "Blogs" },
|
{ "name": "Blogs" },
|
||||||
{ "name": "Portfolios" },
|
{ "name": "Portfolios" },
|
||||||
@@ -210,6 +211,66 @@
|
|||||||
"responses": { "200": { "description": "{ form, values }" } }
|
"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": {
|
"/tenants/{domain}/store-items": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Store"],
|
"tags": ["Store"],
|
||||||
|
|||||||
+116
-11
@@ -14,16 +14,17 @@ model User {
|
|||||||
email String? @db.VarChar(255)
|
email String? @db.VarChar(255)
|
||||||
firstName String? @map("first_name") @db.VarChar(100)
|
firstName String? @map("first_name") @db.VarChar(100)
|
||||||
lastName String? @map("last_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")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
|
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
|
||||||
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
|
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
|
||||||
createdAt DateTime @default(now()) @map("created_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)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
profile Json @default("{}")
|
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")
|
oldId BigInt? @map("old_id")
|
||||||
addresses Address[]
|
addresses Address[]
|
||||||
|
authoredBlogs blogs[] @relation("BlogAuthor")
|
||||||
businessCustomers BusinessCustomer[]
|
businessCustomers BusinessCustomer[]
|
||||||
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
|
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
|
||||||
businessUsers BusinessUser[] @relation("BusinessMember")
|
businessUsers BusinessUser[] @relation("BusinessMember")
|
||||||
@@ -35,13 +36,13 @@ model User {
|
|||||||
mediaUploaded Media[]
|
mediaUploaded Media[]
|
||||||
ordersCreated Order[] @relation("OrderCreator")
|
ordersCreated Order[] @relation("OrderCreator")
|
||||||
orders Order[] @relation("OrderCustomer")
|
orders Order[] @relation("OrderCustomer")
|
||||||
|
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
||||||
shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator")
|
shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator")
|
||||||
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
|
||||||
transactionsCreated Transaction[] @relation("TransactionCreator")
|
transactionsCreated Transaction[] @relation("TransactionCreator")
|
||||||
transactions Transaction[] @relation("TransactionCustomer")
|
transactions Transaction[] @relation("TransactionCustomer")
|
||||||
|
userProducts UserProduct[]
|
||||||
userRoles UserRole[]
|
userRoles UserRole[]
|
||||||
authoredBlogs blogs[] @relation("BlogAuthor")
|
|
||||||
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
|
|
||||||
|
|
||||||
@@index([cellNumber], map: "idx_users_cell_number")
|
@@index([cellNumber], map: "idx_users_cell_number")
|
||||||
@@map("users")
|
@@map("users")
|
||||||
@@ -98,6 +99,8 @@ model Business {
|
|||||||
storeItems StoreItem[]
|
storeItems StoreItem[]
|
||||||
storeSpecials StoreSpecial[]
|
storeSpecials StoreSpecial[]
|
||||||
transactions Transaction[]
|
transactions Transaction[]
|
||||||
|
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
|
||||||
|
userProducts UserProduct[]
|
||||||
website_brand_groups website_brand_groups[]
|
website_brand_groups website_brand_groups[]
|
||||||
website_category_groups website_category_groups[]
|
website_category_groups website_category_groups[]
|
||||||
website_sliders website_sliders[]
|
website_sliders website_sliders[]
|
||||||
@@ -280,6 +283,7 @@ model Media {
|
|||||||
attachments MediaAttachment[]
|
attachments MediaAttachment[]
|
||||||
portfolios portfolios[]
|
portfolios portfolios[]
|
||||||
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
featuredProducts Product[] @relation("ProductFeaturedMedia")
|
||||||
|
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
|
||||||
website_slider_slides website_slider_slides[]
|
website_slider_slides website_slider_slides[]
|
||||||
|
|
||||||
@@index([businessId], map: "idx_media_business_id")
|
@@index([businessId], map: "idx_media_business_id")
|
||||||
@@ -385,6 +389,7 @@ model Brand {
|
|||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
|
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
|
||||||
products Product[]
|
products Product[]
|
||||||
|
userProducts UserProduct[]
|
||||||
website_brand_group_items website_brand_group_items[]
|
website_brand_group_items website_brand_group_items[]
|
||||||
|
|
||||||
@@unique([businessId, slug], map: "brands_business_slug_unique")
|
@@unique([businessId, slug], map: "brands_business_slug_unique")
|
||||||
@@ -489,6 +494,7 @@ model CategoryTechnicalFormField {
|
|||||||
options CategoryTechnicalFormFieldOption[]
|
options CategoryTechnicalFormFieldOption[]
|
||||||
form CategoryTechnicalForm @relation(fields: [formId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
form CategoryTechnicalForm @relation(fields: [formId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
values ProductTechnicalFieldValue[]
|
values ProductTechnicalFieldValue[]
|
||||||
|
userProductFieldValues UserProductTechnicalFieldValue[]
|
||||||
|
|
||||||
@@unique([formId, fieldKey], map: "category_technical_form_fields_unique_key")
|
@@unique([formId, fieldKey], map: "category_technical_form_fields_unique_key")
|
||||||
@@index([formId], map: "idx_category_technical_form_fields_form_id")
|
@@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)
|
field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
multiSelectValues ProductTechnicalFieldValueOption[]
|
multiSelectValues ProductTechnicalFieldValueOption[]
|
||||||
selectedValues ProductTechnicalFieldValue[]
|
selectedValues ProductTechnicalFieldValue[]
|
||||||
|
userProductMultiSelectValues UserProductTechnicalFieldValueOption[]
|
||||||
|
userProductSelectedValues UserProductTechnicalFieldValue[]
|
||||||
|
|
||||||
@@unique([fieldId, value], map: "category_technical_form_field_options_unique_value")
|
@@unique([fieldId, value], map: "category_technical_form_field_options_unique_value")
|
||||||
@@index([fieldId], map: "idx_category_technical_form_field_options_field_id")
|
@@index([fieldId], map: "idx_category_technical_form_field_options_field_id")
|
||||||
@@ -634,10 +642,7 @@ model blogs {
|
|||||||
model portfolios {
|
model portfolios {
|
||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
business_id BigInt
|
business_id BigInt
|
||||||
author_id BigInt?
|
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
title_fa String? @db.VarChar(255)
|
|
||||||
title_en String? @db.VarChar(255)
|
|
||||||
slug String @db.VarChar(255)
|
slug String @db.VarChar(255)
|
||||||
description String?
|
description String?
|
||||||
content Json @default("{}")
|
content Json @default("{}")
|
||||||
@@ -651,7 +656,10 @@ model portfolios {
|
|||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
updated_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
old_id BigInt?
|
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)
|
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
media Media? @relation(fields: [featured_media_id], references: [id], 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)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
children City[] @relation("CityTree")
|
children City[] @relation("CityTree")
|
||||||
|
userProductsAsCity UserProduct[] @relation("UserProductCity")
|
||||||
|
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
|
||||||
|
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
|
||||||
|
|
||||||
@@index([level], map: "idx_cities_level")
|
@@index([level], map: "idx_cities_level")
|
||||||
@@index([level, parentId, sortOrder], map: "idx_cities_level_parent")
|
@@index([level, parentId, sortOrder], map: "idx_cities_level_parent")
|
||||||
@@ -959,11 +970,11 @@ model StoreSpecial {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
businessId BigInt @map("business_id")
|
businessId BigInt @map("business_id")
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
key String @db.VarChar(100)
|
|
||||||
sortOrder Int @default(0) @map("sort_order")
|
sortOrder Int @default(0) @map("sort_order")
|
||||||
isActive Boolean @default(true) @map("is_active")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_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)
|
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||||
|
key String @db.VarChar(100)
|
||||||
items StoreSpecialItem[]
|
items StoreSpecialItem[]
|
||||||
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
@@ -989,11 +1000,11 @@ model website_brand_groups {
|
|||||||
id BigInt @id @default(autoincrement())
|
id BigInt @id @default(autoincrement())
|
||||||
business_id BigInt
|
business_id BigInt
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
key String @db.VarChar(100)
|
|
||||||
sort_order Int @default(0)
|
sort_order Int @default(0)
|
||||||
is_active Boolean @default(true)
|
is_active Boolean @default(true)
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_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[]
|
website_brand_group_items website_brand_group_items[]
|
||||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
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())
|
id BigInt @id @default(autoincrement())
|
||||||
business_id BigInt
|
business_id BigInt
|
||||||
title String @db.VarChar(255)
|
title String @db.VarChar(255)
|
||||||
key String @db.VarChar(100)
|
|
||||||
sort_order Int @default(0)
|
sort_order Int @default(0)
|
||||||
is_active Boolean @default(true)
|
is_active Boolean @default(true)
|
||||||
created_at DateTime @default(now()) @db.Timestamptz(6)
|
created_at DateTime @default(now()) @db.Timestamptz(6)
|
||||||
updated_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[]
|
website_category_group_items website_category_group_items[]
|
||||||
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
|
||||||
|
|
||||||
@@ -1230,6 +1241,88 @@ model InvoiceAccount {
|
|||||||
@@map("invoice_accounts")
|
@@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 {
|
enum MediaType {
|
||||||
image
|
image
|
||||||
video
|
video
|
||||||
@@ -1242,6 +1335,7 @@ enum MediaEntityType {
|
|||||||
blog
|
blog
|
||||||
portfolio
|
portfolio
|
||||||
customer
|
customer
|
||||||
|
user_product
|
||||||
|
|
||||||
@@map("media_entity_type")
|
@@map("media_entity_type")
|
||||||
}
|
}
|
||||||
@@ -1258,6 +1352,7 @@ enum ContentStatus {
|
|||||||
draft
|
draft
|
||||||
published
|
published
|
||||||
archived
|
archived
|
||||||
|
rejected
|
||||||
|
|
||||||
@@map("content_status")
|
@@map("content_status")
|
||||||
}
|
}
|
||||||
@@ -1283,6 +1378,7 @@ enum CityLevel {
|
|||||||
country
|
country
|
||||||
province
|
province
|
||||||
city
|
city
|
||||||
|
district
|
||||||
|
|
||||||
@@map("city_level")
|
@@map("city_level")
|
||||||
}
|
}
|
||||||
@@ -1339,3 +1435,12 @@ enum InvoiceStatus {
|
|||||||
|
|
||||||
@@map("invoice_status")
|
@@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 { ShoppingCardsModule } from './shopping-cards/shopping-cards.module';
|
||||||
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
|
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
|
||||||
import { FavoritesModule } from './favorites/favorites.module';
|
import { FavoritesModule } from './favorites/favorites.module';
|
||||||
|
import { UserProductsModule } from './user-products/user-products.module';
|
||||||
import { BrandsModule } from './brands/brands.module';
|
import { BrandsModule } from './brands/brands.module';
|
||||||
import { WebsiteModule } from './website/website.module';
|
import { WebsiteModule } from './website/website.module';
|
||||||
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
|
||||||
@@ -69,6 +70,7 @@ import { PublicSmsModule } from './public-sms/public-sms.module';
|
|||||||
ShoppingCardsModule,
|
ShoppingCardsModule,
|
||||||
ContactSubmissionsModule,
|
ContactSubmissionsModule,
|
||||||
FavoritesModule,
|
FavoritesModule,
|
||||||
|
UserProductsModule,
|
||||||
BrandsModule,
|
BrandsModule,
|
||||||
WebsiteModule,
|
WebsiteModule,
|
||||||
WebsiteDocsModule,
|
WebsiteDocsModule,
|
||||||
|
|||||||
@@ -25,12 +25,14 @@ import { normalizeBusinessPrimaryColorId } from '../business-settings/business-p
|
|||||||
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
|
||||||
import {
|
import {
|
||||||
normalizeDashboardLocale,
|
normalizeDashboardLocale,
|
||||||
|
normalizeDashboardThemeMode,
|
||||||
normalizeEnabledBusinessModules,
|
normalizeEnabledBusinessModules,
|
||||||
normalizeHomeCharts,
|
normalizeHomeCharts,
|
||||||
} from '../business-settings/business-settings.util';
|
} from '../business-settings/business-settings.util';
|
||||||
import type {
|
import type {
|
||||||
BusinessModuleId,
|
BusinessModuleId,
|
||||||
DashboardLocale,
|
DashboardLocale,
|
||||||
|
DashboardThemeMode,
|
||||||
HomeChartId,
|
HomeChartId,
|
||||||
} from '../business-settings/business-settings.types';
|
} from '../business-settings/business-settings.types';
|
||||||
import {
|
import {
|
||||||
@@ -64,6 +66,7 @@ type BusinessRow = {
|
|||||||
ownerCellNumber: string | null;
|
ownerCellNumber: string | null;
|
||||||
primaryColor: string | null;
|
primaryColor: string | null;
|
||||||
defaultLocale: string | null;
|
defaultLocale: string | null;
|
||||||
|
themeMode: string | null;
|
||||||
enabledModules: unknown;
|
enabledModules: unknown;
|
||||||
homeCharts: unknown;
|
homeCharts: unknown;
|
||||||
};
|
};
|
||||||
@@ -145,6 +148,7 @@ export class BusinessAdminService {
|
|||||||
own."ownerCellNumber" AS "ownerCellNumber",
|
own."ownerCellNumber" AS "ownerCellNumber",
|
||||||
b.settings->'branding'->>'primaryColor' AS "primaryColor",
|
b.settings->'branding'->>'primaryColor' AS "primaryColor",
|
||||||
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
|
||||||
|
b.settings->'branding'->>'themeMode' AS "themeMode",
|
||||||
b.settings->'modules'->'enabled' AS "enabledModules",
|
b.settings->'modules'->'enabled' AS "enabledModules",
|
||||||
b.settings->'modules'->'charts' AS "homeCharts"
|
b.settings->'modules'->'charts' AS "homeCharts"
|
||||||
FROM businesses b
|
FROM businesses b
|
||||||
@@ -201,6 +205,9 @@ export class BusinessAdminService {
|
|||||||
defaultLocale: normalizeDashboardLocale(
|
defaultLocale: normalizeDashboardLocale(
|
||||||
item.defaultLocale,
|
item.defaultLocale,
|
||||||
) as DashboardLocale,
|
) as DashboardLocale,
|
||||||
|
themeMode: normalizeDashboardThemeMode(
|
||||||
|
item.themeMode,
|
||||||
|
) as DashboardThemeMode,
|
||||||
enabledModules,
|
enabledModules,
|
||||||
moduleCount: enabledModules.length,
|
moduleCount: enabledModules.length,
|
||||||
homeCharts,
|
homeCharts,
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ export class BusinessSettingsService {
|
|||||||
),
|
),
|
||||||
defaultLocale:
|
defaultLocale:
|
||||||
dto.branding.defaultLocale ?? current.branding.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 const DEFAULT_BUSINESS_DASHBOARD_LOCALE: DashboardLocale = 'fa';
|
||||||
|
|
||||||
|
export type DashboardThemeMode = 'light' | 'dark';
|
||||||
|
|
||||||
|
export const DEFAULT_BUSINESS_THEME_MODE: DashboardThemeMode = 'light';
|
||||||
|
|
||||||
export type BrandingSettings = {
|
export type BrandingSettings = {
|
||||||
primaryColor: BusinessPrimaryColorId;
|
primaryColor: BusinessPrimaryColorId;
|
||||||
/** Default UI language for business + customer dashboards. */
|
/** Default UI language for business + customer dashboards. */
|
||||||
defaultLocale: DashboardLocale;
|
defaultLocale: DashboardLocale;
|
||||||
|
/** Dashboard surface theme (neutral light/dark). Independent of primary brand color. */
|
||||||
|
themeMode: DashboardThemeMode;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type DashboardCommentsSettings = {
|
export type DashboardCommentsSettings = {
|
||||||
@@ -38,8 +44,8 @@ export type StoreSettings = {
|
|||||||
orderProcessSteps: OrderProcessStep[];
|
orderProcessSteps: OrderProcessStep[];
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Optional CMS modules a business can have. Always-on areas (customers, website, etc.) are not listed. */
|
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
|
||||||
export const BUSINESS_MODULE_IDS = [
|
export const BUSINESS_DASHBOARD_MODULE_IDS = [
|
||||||
'products',
|
'products',
|
||||||
'store',
|
'store',
|
||||||
'portfolio',
|
'portfolio',
|
||||||
@@ -48,6 +54,18 @@ export const BUSINESS_MODULE_IDS = [
|
|||||||
'videos',
|
'videos',
|
||||||
] as const;
|
] 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];
|
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number];
|
||||||
|
|
||||||
/** Home dashboard chart types (super-admin selectable). */
|
/** 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[] = [
|
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
|
||||||
...BUSINESS_MODULE_IDS,
|
...BUSINESS_DASHBOARD_MODULE_IDS,
|
||||||
];
|
];
|
||||||
|
|
||||||
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
|
||||||
@@ -116,6 +137,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
|
|||||||
branding: {
|
branding: {
|
||||||
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
|
||||||
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
||||||
|
themeMode: DEFAULT_BUSINESS_THEME_MODE,
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
comments: { autoApprove: false },
|
comments: { autoApprove: false },
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ import {
|
|||||||
BusinessSettings,
|
BusinessSettings,
|
||||||
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
|
||||||
DEFAULT_BUSINESS_SETTINGS,
|
DEFAULT_BUSINESS_SETTINGS,
|
||||||
|
DEFAULT_BUSINESS_THEME_MODE,
|
||||||
DEFAULT_ENABLED_BUSINESS_MODULES,
|
DEFAULT_ENABLED_BUSINESS_MODULES,
|
||||||
DEFAULT_HOME_CHARTS,
|
DEFAULT_HOME_CHARTS,
|
||||||
DEFAULT_ORDER_PROCESS_STEPS,
|
DEFAULT_ORDER_PROCESS_STEPS,
|
||||||
HOME_CHART_IDS,
|
HOME_CHART_IDS,
|
||||||
type DashboardLocale,
|
type DashboardLocale,
|
||||||
|
type DashboardThemeMode,
|
||||||
type HomeChartId,
|
type HomeChartId,
|
||||||
OrderProcessStep,
|
OrderProcessStep,
|
||||||
} from './business-settings.types';
|
} from './business-settings.types';
|
||||||
@@ -68,6 +70,12 @@ export function normalizeDashboardLocale(value: unknown): DashboardLocale {
|
|||||||
return value === 'en' || value === 'fa' ? value : DEFAULT_BUSINESS_DASHBOARD_LOCALE;
|
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) {
|
function readBoolean(value: unknown, fallback: boolean) {
|
||||||
return typeof value === 'boolean' ? value : fallback;
|
return typeof value === 'boolean' ? value : fallback;
|
||||||
}
|
}
|
||||||
@@ -122,6 +130,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
|
|||||||
branding: {
|
branding: {
|
||||||
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
|
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
|
||||||
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
|
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
|
||||||
|
themeMode: normalizeDashboardThemeMode(branding.themeMode),
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
comments: {
|
comments: {
|
||||||
@@ -166,6 +175,7 @@ export function mergeBusinessSettings(
|
|||||||
patch.branding?.primaryColor ?? current.branding.primaryColor,
|
patch.branding?.primaryColor ?? current.branding.primaryColor,
|
||||||
defaultLocale:
|
defaultLocale:
|
||||||
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
|
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
|
||||||
|
themeMode: patch.branding?.themeMode ?? current.branding.themeMode,
|
||||||
},
|
},
|
||||||
dashboard: {
|
dashboard: {
|
||||||
comments: {
|
comments: {
|
||||||
|
|||||||
@@ -27,6 +27,11 @@ class BrandingSettingsDto {
|
|||||||
@IsString()
|
@IsString()
|
||||||
@IsIn(['en', 'fa'])
|
@IsIn(['en', 'fa'])
|
||||||
defaultLocale?: 'en' | 'fa';
|
defaultLocale?: 'en' | 'fa';
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@IsIn(['light', 'dark'])
|
||||||
|
themeMode?: 'light' | 'dark';
|
||||||
}
|
}
|
||||||
|
|
||||||
class DashboardCommentsSettingsDto {
|
class DashboardCommentsSettingsDto {
|
||||||
|
|||||||
@@ -14,13 +14,43 @@ import {
|
|||||||
} from './dto/category-technical-form.dto';
|
} from './dto/category-technical-form.dto';
|
||||||
|
|
||||||
function slugifyKey(value: string): string {
|
function slugifyKey(value: string): string {
|
||||||
return (
|
const ascii = value
|
||||||
value
|
|
||||||
.toLowerCase()
|
.toLowerCase()
|
||||||
.trim()
|
.trim()
|
||||||
.replace(/[^a-z0-9]+/g, '-')
|
.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()
|
@Injectable()
|
||||||
@@ -88,15 +118,7 @@ export class CategoryTechnicalFormService {
|
|||||||
const usedKeys = new Set<string>();
|
const usedKeys = new Set<string>();
|
||||||
|
|
||||||
for (const [index, field] of dto.fields.entries()) {
|
for (const [index, field] of dto.fields.entries()) {
|
||||||
let fieldKey = slugifyKey(field.label);
|
const fieldKey = uniqueSlug(slugifyKey(field.label), usedKeys);
|
||||||
if (usedKeys.has(fieldKey)) {
|
|
||||||
let suffix = 2;
|
|
||||||
while (usedKeys.has(`${fieldKey}-${suffix}`)) {
|
|
||||||
suffix += 1;
|
|
||||||
}
|
|
||||||
fieldKey = `${fieldKey}-${suffix}`;
|
|
||||||
}
|
|
||||||
usedKeys.add(fieldKey);
|
|
||||||
|
|
||||||
const createdField = await tx.categoryTechnicalFormField.create({
|
const createdField = await tx.categoryTechnicalFormField.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -113,12 +135,13 @@ export class CategoryTechnicalFormService {
|
|||||||
const uniqueOptions = [
|
const uniqueOptions = [
|
||||||
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
|
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
|
||||||
];
|
];
|
||||||
|
const usedValues = new Set<string>();
|
||||||
|
|
||||||
await tx.categoryTechnicalFormFieldOption.createMany({
|
await tx.categoryTechnicalFormFieldOption.createMany({
|
||||||
data: uniqueOptions.map((label, optionIndex) => ({
|
data: uniqueOptions.map((label, optionIndex) => ({
|
||||||
fieldId: createdField.id,
|
fieldId: createdField.id,
|
||||||
label,
|
label,
|
||||||
value: slugifyKey(label) || `option-${optionIndex + 1}`,
|
value: uniqueSlug(slugifyKey(label), usedValues),
|
||||||
sortOrder: optionIndex,
|
sortOrder: optionIndex,
|
||||||
})),
|
})),
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -26,25 +26,51 @@ export class CitiesService {
|
|||||||
...(query.level ? { level: query.level } : {}),
|
...(query.level ? { level: query.level } : {}),
|
||||||
};
|
};
|
||||||
|
|
||||||
if (query.parentId) {
|
let parent:
|
||||||
where.parentId = BigInt(query.parentId);
|
| { id: bigint; level: CityLevel }
|
||||||
} else if (query.parentSlug) {
|
| null = null;
|
||||||
const parent = await this.prisma.city.findFirst({
|
|
||||||
where: { slug: query.parentSlug, isActive: true },
|
|
||||||
select: { id: true },
|
|
||||||
});
|
|
||||||
|
|
||||||
|
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) {
|
if (!parent) {
|
||||||
return { items: [] };
|
return { items: [] };
|
||||||
}
|
}
|
||||||
|
|
||||||
where.parentId = parent.id;
|
|
||||||
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
|
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
|
||||||
throw new BadRequestException('parentId or parentSlug is required for this level');
|
throw new BadRequestException('parentId or parentSlug is required for this level');
|
||||||
} else {
|
} else {
|
||||||
where.level = CityLevel.country;
|
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({
|
const items = await this.prisma.city.findMany({
|
||||||
where,
|
where,
|
||||||
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
|
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
|
||||||
|
|||||||
@@ -7,5 +7,6 @@ import { MediaService } from './media.service';
|
|||||||
imports: [AuthModule],
|
imports: [AuthModule],
|
||||||
controllers: [MediaController],
|
controllers: [MediaController],
|
||||||
providers: [MediaService],
|
providers: [MediaService],
|
||||||
|
exports: [MediaService],
|
||||||
})
|
})
|
||||||
export class MediaModule {}
|
export class MediaModule {}
|
||||||
|
|||||||
@@ -99,6 +99,38 @@ export class MediaService {
|
|||||||
return { items };
|
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(
|
async update(
|
||||||
businessIdRaw: string,
|
businessIdRaw: string,
|
||||||
mediaIdRaw: string,
|
mediaIdRaw: string,
|
||||||
@@ -371,4 +403,20 @@ export class MediaService {
|
|||||||
throw new ForbiddenException('You cannot delete media for this business');
|
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,
|
domain: normalizedHost,
|
||||||
primaryColor: settings.branding.primaryColor,
|
primaryColor: settings.branding.primaryColor,
|
||||||
defaultLocale: settings.branding.defaultLocale,
|
defaultLocale: settings.branding.defaultLocale,
|
||||||
|
themeMode: settings.branding.themeMode,
|
||||||
enabledModules: settings.modules.enabled,
|
enabledModules: settings.modules.enabled,
|
||||||
homeCharts: settings.modules.charts,
|
homeCharts: settings.modules.charts,
|
||||||
logoUrl: media?.logoMedia?.publicUrl ?? null,
|
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
|
### Typical bootstrap sequence
|
||||||
1. `GET /tenants/{domain}` → branding + `businessId`
|
1. `GET /tenants/{domain}` → branding + `businessId`
|
||||||
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
|
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).
|
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`
|
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**.
|
If OpenAPI and this brief conflict, **OpenAPI wins**.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|||||||
@@ -33,6 +33,14 @@
|
|||||||
"key": "productSlug",
|
"key": "productSlug",
|
||||||
"value": ""
|
"value": ""
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"key": "userProductId",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"key": "userProductSlug",
|
||||||
|
"value": ""
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"key": "blogId",
|
"key": "blogId",
|
||||||
"value": ""
|
"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",
|
"name": "Store Items",
|
||||||
"item": [
|
"item": [
|
||||||
|
|||||||
@@ -88,10 +88,17 @@
|
|||||||
<ol>
|
<ol>
|
||||||
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
|
<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><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>
|
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
|
||||||
</ol>
|
</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>
|
<h2>For a new website AI / designer</h2>
|
||||||
<ol>
|
<ol>
|
||||||
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
|
<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": "Homepage" },
|
||||||
{ "name": "Categories" },
|
{ "name": "Categories" },
|
||||||
{ "name": "Products" },
|
{ "name": "Products" },
|
||||||
|
{ "name": "User Products" },
|
||||||
{ "name": "Store" },
|
{ "name": "Store" },
|
||||||
{ "name": "Blogs" },
|
{ "name": "Blogs" },
|
||||||
{ "name": "Portfolios" },
|
{ "name": "Portfolios" },
|
||||||
@@ -210,6 +211,66 @@
|
|||||||
"responses": { "200": { "description": "{ form, values }" } }
|
"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": {
|
"/tenants/{domain}/store-items": {
|
||||||
"get": {
|
"get": {
|
||||||
"tags": ["Store"],
|
"tags": ["Store"],
|
||||||
|
|||||||
Reference in New Issue
Block a user