Add public user-products storefront API and website docs.

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

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Alireza Hassani
2026-08-10 00:19:13 +03:30
co-authored by Cursor
parent 158523df7b
commit 953b87b616
32 changed files with 3326 additions and 138 deletions
+188
View File
@@ -0,0 +1,188 @@
-- Customer / user products (stock listings)
-- Like products, but: no variations/options; location (country/city/district);
-- categories come from main product categories via category_assignments;
-- technical data reuses category technical forms.
-- ---------------------------------------------------------------------------
-- entity type for assignments / media attachments
-- ---------------------------------------------------------------------------
ALTER TYPE media_entity_type ADD VALUE IF NOT EXISTS 'user_product';
-- ---------------------------------------------------------------------------
-- cities: add district under city (country → province → city → district)
-- ---------------------------------------------------------------------------
ALTER TYPE city_level ADD VALUE IF NOT EXISTS 'district';
CREATE OR REPLACE FUNCTION cities_validate_parent_level()
RETURNS TRIGGER AS $$
DECLARE
parent_level city_level;
BEGIN
IF NEW.level = 'country' THEN
RETURN NEW;
END IF;
SELECT level INTO parent_level FROM cities WHERE id = NEW.parent_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'parent city not found';
END IF;
IF NEW.level = 'province' AND parent_level <> 'country' THEN
RAISE EXCEPTION 'province parent must be a country';
END IF;
IF NEW.level = 'city' AND parent_level <> 'province' THEN
RAISE EXCEPTION 'city parent must be a province';
END IF;
IF NEW.level = 'district' AND parent_level <> 'city' THEN
RAISE EXCEPTION 'district parent must be a city';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- user_products
-- ---------------------------------------------------------------------------
CREATE TABLE user_products (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_id BIGINT NOT NULL,
title VARCHAR(255) NOT NULL,
slug VARCHAR(255) NOT NULL,
description TEXT,
content JSONB NOT NULL DEFAULT '{}',
price NUMERIC(12, 2),
compare_at_price NUMERIC(12, 2),
sku VARCHAR(100),
stock_quantity INTEGER,
status content_status NOT NULL DEFAULT 'draft',
featured_media_id BIGINT,
brand_id BIGINT,
country_id BIGINT NOT NULL,
city_id BIGINT NOT NULL,
district_id BIGINT,
sort_order INTEGER NOT NULL DEFAULT 0,
published_at TIMESTAMPTZ,
metadata JSONB NOT NULL DEFAULT '{}',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT user_products_business_slug_unique UNIQUE (business_id, slug),
CONSTRAINT user_products_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT user_products_user_id_fkey
FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE,
CONSTRAINT user_products_featured_media_id_fkey
FOREIGN KEY (featured_media_id) REFERENCES media (id) ON DELETE SET NULL,
CONSTRAINT user_products_brand_id_fkey
FOREIGN KEY (brand_id) REFERENCES brands (id) ON DELETE SET NULL,
CONSTRAINT user_products_country_id_fkey
FOREIGN KEY (country_id) REFERENCES cities (id) ON DELETE RESTRICT,
CONSTRAINT user_products_city_id_fkey
FOREIGN KEY (city_id) REFERENCES cities (id) ON DELETE RESTRICT,
CONSTRAINT user_products_district_id_fkey
FOREIGN KEY (district_id) REFERENCES cities (id) ON DELETE SET NULL,
CONSTRAINT user_products_price_non_negative CHECK (price IS NULL OR price >= 0),
CONSTRAINT user_products_compare_price_non_negative CHECK (compare_at_price IS NULL OR compare_at_price >= 0),
CONSTRAINT user_products_stock_non_negative CHECK (stock_quantity IS NULL OR stock_quantity >= 0)
);
CREATE INDEX idx_user_products_business_id ON user_products (business_id);
CREATE INDEX idx_user_products_user_id ON user_products (user_id);
CREATE INDEX idx_user_products_business_user ON user_products (business_id, user_id);
CREATE INDEX idx_user_products_business_status ON user_products (business_id, status);
CREATE INDEX idx_user_products_business_published ON user_products (business_id, published_at DESC);
CREATE INDEX idx_user_products_brand_id ON user_products (brand_id);
CREATE INDEX idx_user_products_country_id ON user_products (country_id);
CREATE INDEX idx_user_products_city_id ON user_products (city_id);
CREATE INDEX idx_user_products_district_id ON user_products (district_id);
CREATE TRIGGER user_products_set_updated_at
BEFORE UPDATE ON user_products
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
-- ---------------------------------------------------------------------------
-- technical data (same shape as product technical field values)
-- ---------------------------------------------------------------------------
CREATE TABLE user_product_technical_field_values (
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
business_id BIGINT NOT NULL,
user_product_id BIGINT NOT NULL,
field_id BIGINT NOT NULL,
text_value TEXT,
option_id BIGINT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT user_product_technical_field_values_business_id_fkey
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_values_user_product_id_fkey
FOREIGN KEY (user_product_id) REFERENCES user_products (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_values_field_id_fkey
FOREIGN KEY (field_id) REFERENCES category_technical_form_fields (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_values_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE SET NULL,
CONSTRAINT user_product_technical_field_values_unique
UNIQUE (user_product_id, field_id)
);
CREATE INDEX idx_user_product_technical_field_values_user_product_id
ON user_product_technical_field_values (user_product_id);
CREATE INDEX idx_user_product_technical_field_values_business_id
ON user_product_technical_field_values (business_id);
CREATE TRIGGER user_product_technical_field_values_set_updated_at
BEFORE UPDATE ON user_product_technical_field_values
FOR EACH ROW
EXECUTE FUNCTION set_updated_at();
CREATE TABLE user_product_technical_field_value_options (
field_value_id BIGINT NOT NULL,
option_id BIGINT NOT NULL,
CONSTRAINT user_product_technical_field_value_options_pkey
PRIMARY KEY (field_value_id, option_id),
CONSTRAINT user_product_technical_field_value_options_field_value_id_fkey
FOREIGN KEY (field_value_id) REFERENCES user_product_technical_field_values (id) ON DELETE CASCADE,
CONSTRAINT user_product_technical_field_value_options_option_id_fkey
FOREIGN KEY (option_id) REFERENCES category_technical_form_field_options (id) ON DELETE CASCADE
);
CREATE INDEX idx_user_product_technical_field_value_options_option_id
ON user_product_technical_field_value_options (option_id);
-- ---------------------------------------------------------------------------
-- permissions (business dashboard moderation)
-- ---------------------------------------------------------------------------
INSERT INTO permissions (name, slug, group_name, description) VALUES
('View user products', 'user_products.read', 'user_products', 'View customer product listings'),
('Create user products', 'user_products.create', 'user_products', 'Create customer product listings'),
('Update user products', 'user_products.update', 'user_products', 'Edit customer product listings'),
('Delete user products', 'user_products.delete', 'user_products', 'Delete customer product listings')
ON CONFLICT (slug) DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug LIKE 'user_products.%'
WHERE r.slug IN ('business_owner', 'owner', 'admin')
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug IN ('user_products.read', 'user_products.create', 'user_products.update')
WHERE r.slug = 'editor'
ON CONFLICT DO NOTHING;
INSERT INTO role_permissions (role_id, permission_id)
SELECT r.id, p.id
FROM roles r
JOIN permissions p ON p.slug = 'user_products.read'
WHERE r.slug = 'viewer'
ON CONFLICT DO NOTHING;
@@ -0,0 +1,86 @@
-- Allow cities directly under a country (province optional).
-- Seed Iraq, Turkey, UAE + major cities (Iran already seeded in 004_iran_cities).
CREATE OR REPLACE FUNCTION cities_validate_parent_level()
RETURNS TRIGGER AS $$
DECLARE
parent_level city_level;
BEGIN
IF NEW.level = 'country' THEN
RETURN NEW;
END IF;
SELECT level INTO parent_level FROM cities WHERE id = NEW.parent_id;
IF NOT FOUND THEN
RAISE EXCEPTION 'parent city not found';
END IF;
IF NEW.level = 'province' AND parent_level <> 'country' THEN
RAISE EXCEPTION 'province parent must be a country';
END IF;
-- Province is optional: city may hang under a province or directly under a country.
IF NEW.level = 'city' AND parent_level NOT IN ('province', 'country') THEN
RAISE EXCEPTION 'city parent must be a province or country';
END IF;
IF NEW.level = 'district' AND parent_level <> 'city' THEN
RAISE EXCEPTION 'district parent must be a city';
END IF;
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
-- ---------------------------------------------------------------------------
-- Countries (skip if slug already exists)
-- ---------------------------------------------------------------------------
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT NULL, 'country', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM (
VALUES
('ایران', 'Iran', '98', 'iran', 1),
('عراق', 'Iraq', '964', 'iraq', 2),
('ترکیه', 'Turkey', '90', 'turkey', 3),
('امارات', 'UAE', '971', 'uae', 4)
) AS v(name_fa, name_en, landline_code, slug, sort_order)
WHERE NOT EXISTS (
SELECT 1 FROM cities c WHERE c.slug = v.slug
);
-- ---------------------------------------------------------------------------
-- Major cities under country (direct children; province not required)
-- Iran cities already exist under provinces — API lists them via country parent.
-- ---------------------------------------------------------------------------
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT c.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities c
JOIN (
VALUES
-- Iraq
('iraq', 'بغداد', 'Baghdad', '1', 'baghdad', 1),
('iraq', 'بصره', 'Basra', '40', 'basra', 2),
('iraq', 'اربیل', 'Erbil', '66', 'erbil', 3),
('iraq', 'موصل', 'Mosul', '60', 'mosul', 4),
('iraq', 'نجف', 'Najaf', '33', 'najaf', 5),
('iraq', 'سلیمانیه', 'Sulaymaniyah', '53', 'sulaymaniyah', 6),
('iraq', 'کرکوک', 'Kirkuk', '50', 'kirkuk', 7),
-- Turkey
('turkey', 'استانبول', 'Istanbul', '212', 'istanbul', 1),
('turkey', 'آنکارا', 'Ankara', '312', 'ankara', 2),
('turkey', 'ازمیر', 'Izmir', '232', 'izmir', 3),
('turkey', 'آنتالیا', 'Antalya', '242', 'antalya', 4),
('turkey', 'بورسا', 'Bursa', '224', 'bursa', 5),
('turkey', 'غازی عینتاب', 'Gaziantep', '342', 'gaziantep', 6),
-- UAE
('uae', 'دبی', 'Dubai', '4', 'dubai', 1),
('uae', 'ابوظبی', 'Abu Dhabi', '2', 'abu-dhabi', 2),
('uae', 'شارجه', 'Sharjah', '6', 'sharjah', 3),
('uae', 'عجمان', 'Ajman', '6', 'ajman', 4),
('uae', 'رأس الخیمه', 'Ras Al Khaimah', '7', 'ras-al-khaimah', 5)
) AS v(country_slug, name_fa, name_en, landline_code, slug, sort_order)
ON c.slug = v.country_slug AND c.level = 'country'
WHERE NOT EXISTS (
SELECT 1 FROM cities x WHERE x.slug = v.slug
);
@@ -0,0 +1,113 @@
-- Seed Iran provinces + cities if missing (004_iran_cities may never have been applied).
-- Iran country row is expected from 053 (or earlier).
-- Provinces under Iran
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT c.id, 'province', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities c
CROSS JOIN (
VALUES
('آذربایجان شرقی', 'East Azerbaijan', '041', 'east-azerbaijan', 1),
('آذربایجان غربی', 'West Azerbaijan', '044', 'west-azerbaijan', 2),
('اردبیل', 'Ardabil', '045', 'ardabil', 3),
('اصفهان', 'Isfahan', '031', 'isfahan', 4),
('البرز', 'Alborz', '026', 'alborz', 5),
('ایلام', 'Ilam', '084', 'ilam', 6),
('بوشهر', 'Bushehr', '077', 'bushehr', 7),
('تهران', 'Tehran', '021', 'tehran-province', 8),
('چهارمحال و بختیاری', 'Chaharmahal and Bakhtiari', '038', 'chaharmahal-bakhtiari', 9),
('خراسان جنوبی', 'South Khorasan', '056', 'south-khorasan', 10),
('خراسان رضوی', 'Razavi Khorasan', '051', 'razavi-khorasan', 11),
('خراسان شمالی', 'North Khorasan', '058', 'north-khorasan', 12),
('خوزستان', 'Khuzestan', '061', 'khuzestan', 13),
('زنجان', 'Zanjan', '024', 'zanjan', 14),
('سمنان', 'Semnan', '023', 'semnan', 15),
('سیستان و بلوچستان', 'Sistan and Baluchestan', '054', 'sistan-baluchestan', 16),
('فارس', 'Fars', '071', 'fars', 17),
('قزوین', 'Qazvin', '028', 'qazvin', 18),
('قم', 'Qom', '025', 'qom', 19),
('کردستان', 'Kurdistan', '087', 'kurdistan', 20),
('کرمان', 'Kerman', '034', 'kerman', 21),
('کرمانشاه', 'Kermanshah', '083', 'kermanshah', 22),
('کهگیلویه و بویراحمد', 'Kohgiluyeh and Boyer-Ahmad', '074', 'kohgiluyeh-boyer-ahmad', 23),
('گلستان', 'Golestan', '017', 'golestan', 24),
('گیلان', 'Gilan', '013', 'gilan', 25),
('لرستان', 'Lorestan', '066', 'lorestan', 26),
('مازندران', 'Mazandaran', '011', 'mazandaran', 27),
('مرکزی', 'Markazi', '086', 'markazi', 28),
('هرمزگان', 'Hormozgan', '076', 'hormozgan', 29),
('همدان', 'Hamadan', '081', 'hamadan', 30),
('یزد', 'Yazd', '035', 'yazd', 31)
) AS v(name_fa, name_en, landline_code, slug, sort_order)
WHERE c.slug = 'iran' AND c.level = 'country'
AND NOT EXISTS (SELECT 1 FROM cities x WHERE x.slug = v.slug);
-- Provincial capitals
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities p
JOIN (
VALUES
('east-azerbaijan', 'تبریز', 'Tabriz', '041', 'tabriz', 1),
('west-azerbaijan', 'ارومیه', 'Urmia', '044', 'urmia', 1),
('ardabil', 'اردبیل', 'Ardabil', '045', 'ardabil-city', 1),
('isfahan', 'اصفهان', 'Isfahan', '031', 'isfahan-city', 1),
('alborz', 'کرج', 'Karaj', '026', 'karaj', 1),
('ilam', 'ایلام', 'Ilam', '084', 'ilam-city', 1),
('bushehr', 'بوشهر', 'Bushehr', '077', 'bushehr-city', 1),
('tehran-province', 'تهران', 'Tehran', '021', 'tehran', 1),
('chaharmahal-bakhtiari', 'شهرکرد', 'Shahrekord', '038', 'shahrekord', 1),
('south-khorasan', 'بیرجند', 'Birjand', '056', 'birjand', 1),
('razavi-khorasan', 'مشهد', 'Mashhad', '051', 'mashhad', 1),
('north-khorasan', 'بجنورد', 'Bojnord', '058', 'bojnord', 1),
('khuzestan', 'اهواز', 'Ahvaz', '061', 'ahvaz', 1),
('zanjan', 'زنجان', 'Zanjan', '024', 'zanjan-city', 1),
('semnan', 'سمنان', 'Semnan', '023', 'semnan-city', 1),
('sistan-baluchestan', 'زاهدان', 'Zahedan', '054', 'zahedan', 1),
('fars', 'شیراز', 'Shiraz', '071', 'shiraz', 1),
('qazvin', 'قزوین', 'Qazvin', '028', 'qazvin-city', 1),
('qom', 'قم', 'Qom', '025', 'qom-city', 1),
('kurdistan', 'سنندج', 'Sanandaj', '087', 'sanandaj', 1),
('kerman', 'کرمان', 'Kerman', '034', 'kerman-city', 1),
('kermanshah', 'کرمانشاه', 'Kermanshah', '083', 'kermanshah-city', 1),
('kohgiluyeh-boyer-ahmad', 'یاسوج', 'Yasuj', '074', 'yasuj', 1),
('golestan', 'گرگان', 'Gorgan', '017', 'gorgan', 1),
('gilan', 'رشت', 'Rasht', '013', 'rasht', 1),
('lorestan', 'خرم‌آباد', 'Khorramabad', '066', 'khorramabad', 1),
('mazandaran', 'ساری', 'Sari', '011', 'sari', 1),
('markazi', 'اراک', 'Arak', '086', 'arak', 1),
('hormozgan', 'بندرعباس', 'Bandar Abbas', '076', 'bandar-abbas', 1),
('hamadan', 'همدان', 'Hamadan', '081', 'hamadan-city', 1),
('yazd', 'یزد', 'Yazd', '035', 'yazd-city', 1)
) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order)
ON p.slug = v.province_slug AND p.level = 'province'
WHERE NOT EXISTS (SELECT 1 FROM cities x WHERE x.slug = v.slug);
-- Additional major cities
INSERT INTO cities (parent_id, level, name_fa, name_en, landline_code, slug, sort_order)
SELECT p.id, 'city', v.name_fa, v.name_en, v.landline_code, v.slug, v.sort_order
FROM cities p
JOIN (
VALUES
('tehran-province', 'ری', 'Rey', '021', 'rey', 2),
('tehran-province', 'شهریار', 'Shahriar', '021', 'shahriar', 3),
('tehran-province', 'ورامین', 'Varamin', '021', 'varamin', 4),
('isfahan', 'کاشان', 'Kashan', '031', 'kashan', 2),
('isfahan', 'نجف‌آباد', 'Najafabad', '031', 'najafabad', 3),
('fars', 'مرودشت', 'Marvdasht', '071', 'marvdasht', 2),
('fars', 'جهرم', 'Jahrom', '071', 'jahrom', 3),
('khuzestan', 'آبادان', 'Abadan', '061', 'abadan', 2),
('khuzestan', 'دزفول', 'Dezful', '061', 'dezful', 3),
('razavi-khorasan', 'نیشابور', 'Neyshabur', '051', 'neyshabur', 2),
('razavi-khorasan', 'سبزوار', 'Sabzevar', '051', 'sabzevar', 3),
('mazandaran', 'آمل', 'Amol', '011', 'amol', 2),
('mazandaran', 'بابل', 'Babol', '011', 'babol', 3),
('gilan', 'انزلی', 'Bandar Anzali', '013', 'bandar-anzali', 2),
('east-azerbaijan', 'مراغه', 'Maragheh', '041', 'maragheh', 2),
('kerman', 'رفسنجان', 'Rafsanjan', '034', 'rafsanjan', 2),
('alborz', 'فردیس', 'Fardis', '026', 'fardis', 2)
) AS v(province_slug, name_fa, name_en, landline_code, slug, sort_order)
ON p.slug = v.province_slug AND p.level = 'province'
WHERE NOT EXISTS (SELECT 1 FROM cities x WHERE x.slug = v.slug);
SELECT setval(pg_get_serial_sequence('cities', 'id'), COALESCE((SELECT MAX(id) FROM cities), 1));
@@ -0,0 +1,18 @@
-- User product listing extras: currency, delivery note, condition, technical notes
CREATE TYPE user_product_condition AS ENUM (
'new',
'stock',
'needs_repair',
'scrap'
);
ALTER TABLE user_products
ADD COLUMN IF NOT EXISTS price_currency VARCHAR(8) NOT NULL DEFAULT 'IRT',
ADD COLUMN IF NOT EXISTS delivery_note TEXT,
ADD COLUMN IF NOT EXISTS condition user_product_condition NOT NULL DEFAULT 'new',
ADD COLUMN IF NOT EXISTS technical_notes TEXT;
ALTER TABLE user_products
ADD CONSTRAINT user_products_price_currency_format
CHECK (price_currency ~ '^[A-Z]{3,8}$');
@@ -0,0 +1 @@
ALTER TYPE content_status ADD VALUE IF NOT EXISTS 'rejected';
+40 -5
View File
@@ -1,7 +1,7 @@
# Meshkee CMS API — Project Context
> Living reference for developers and AI assistants working on this codebase.
> Last updated: August 5, 2026
> Last updated: August 9, 2026
## What This Project Is
@@ -80,6 +80,8 @@ src/
├── website-docs/ # Public website API docs pack
├── invoices/ # Platform invoices + item templates (super-admin; business-ready schema)
├── public-sms/ # Partner SMS gateway (API key + domain allowlist → Gama)
├── favorites/ # Customer product favorites
├── user-products/ # Customer self-service stock listings (`my-user-products`)
├── prisma/ # PrismaModule + PrismaService
├── redis/ # Redis client + OTP helpers
└── common/ # Shared interceptors (BigInt serialization)
@@ -159,6 +161,10 @@ Example super admin: `+989121111111` / `password`
| `039_invoice_account_holder.sql` | `account_holder_name` on invoice / template accounts |
| `040_invoice_public_id.sql` | Opaque `public_id` for unguessable public invoice links |
| `049_user_name_en.sql` | Optional `users.first_name_en` / `last_name_en` for EN display names |
| `052_user_products.sql` | Customer stock listings (`user_products`) + technical values; `cities.level` adds `district`; `media_entity_type` adds `user_product` |
| `053_cities_country_optional_province.sql` | City may hang under country (province optional); seed Iraq/Turkey/UAE + major cities |
| `054_seed_iran_provinces_cities.sql` | Seed Iran provinces + cities when missing (004 seed may never have run) |
| `055_user_products_listing_fields.sql` | User product listing fields: `price_currency`, `delivery_note`, `condition` (`user_product_condition`), `technical_notes` |
Docker mounts `./database/migrations` into Postgres init — migrations run automatically only on **first** volume creation. Use `migrate.sh` for subsequent migrations.
@@ -170,22 +176,25 @@ Docker mounts `./database/migrations` into Postgres init — migrations run auto
| Enum | Values |
|------|--------|
| `MediaEntityType` | `product`, `blog`, `portfolio` |
| `MediaEntityType` | `product`, `blog`, `portfolio`, `customer`, `user_product` |
| `ContentStatus` | `draft`, `published`, `archived` |
| `VariationType` | `color`, `size`, `custom` |
| `CityLevel` | `country`, `province`, `city`, `district` |
| `OrderStatus` | `pending`, `confirmed`, `processing`, `shipped`, `delivered`, `cancelled` |
| `OrderSource` | `website`, `admin` |
| `InvoiceOwnerScope` | `platform`, `business` |
| `InvoiceStatus` | `draft`, `issued`, `approved`, `paid`, `cancelled` |
| `TechnicalFieldType` | `text`, `textarea`, `select`, `multi_select` |
| `UserProductCondition` | `new`, `stock`, `needs_repair`, `scrap` |
| `MediaType` | `image`, `video` |
### Core relationships
```
Business 1──* Domain
Business 1──* Category (entityType: product|blog|portfolio)
Business 1──* Category (entityType: product|blog|portfolio|customer)
Business 1──* Product
Business 1──* UserProduct (customer stock; no variations; location + technical data)
Business 1──* Media
Category 1──* CategoryVariation 1──* CategoryVariationOption
@@ -194,12 +203,16 @@ CategoryTechnicalFormField 1──* CategoryTechnicalFormFieldOption
Product *──0..1 Category (via CategoryAssignment)
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
Product 1──* ProductVariationValue → CategoryVariationOption (which options this product offers)
Product 1──0..1 StoreItem (one shop listing per product)
StoreItem 1──* StoreItemVariant (purchasable SKUs: price, stock, variation combo)
StoreItemVariant 1──* StoreItemVariantSelection → CategoryVariationOption
Product 1──* ProductTechnicalFieldValue → CategoryTechnicalFormField
UserProduct *── Category (via CategoryAssignment, entityType user_product → product categories)
UserProduct 1──* UserProductTechnicalFieldValue → CategoryTechnicalFormField
UserProduct → City (country, city, optional district)
User 1──* UserProduct
Business 1──* Cart (per customer) 1──* CartItem → ProductVariant
Business 1──* Order 1──* OrderItem → ProductVariant (snapshot on order)
User 1──* Cart, Order (as customer)
@@ -295,6 +308,27 @@ Pattern: `/businesses/:businessId/<resource>`
| GET/POST/PATCH/DELETE | `/products/:id/variants` | Removed — use `/store-items` |
| GET/PUT | `/products/:id/technical-info` | Product technical data |
#### My user products (customer — JWT, must be business customer)
Base: `/businesses/:businessId/my-user-products`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | List current user's user products (paginated) |
| POST | `/` | Create draft user product (category, location, condition, optional technical values) |
| GET | `/categories` | Active product categories for picker (`id`, `name`, `nameFa`, `parentId`) |
| GET | `/categories/:categoryId/technical-form` | Category technical form (customer access; no `categories.read`) |
#### Public user products (storefront — no auth)
Base: `/tenants/:host/user-products`
| Method | Path | Description |
|--------|------|-------------|
| GET | `/` | List published listings (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination) |
| GET | `/:slug` | Details + gallery (`images`, `galleryMediaIds`) + technical values |
| GET | `/:slug/technical-info` | Category technical form + values |
#### Cart (customer — JWT, must be business customer)
| Method | Path | Description |
@@ -583,7 +617,8 @@ See `.env.example` for the full list. Key groups:
| Portfolios | Yes | Yes | Partial (migrate-from-old) | Yes |
| Customer dashboard | Partial | No | Register only | Yes |
| Store checkout (cart, orders) | Yes | Yes | Yes | Yes |
| Customer favorites | — | `favorites.*` seeded | No | No |
| Customer favorites | — | `favorites.*` seeded | Partial | Yes |
| Customer user products | Yes (`052`+`055`) | Admin `user_products.*` + customer JWT | Yes (`my-user-products`, admin, public tenants) | Yes |
---
+8 -1
View File
@@ -31,10 +31,17 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
### User products (customer listings)
Public marketplace listings owned by customers — not catalog `products`.
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
@@ -33,6 +33,14 @@
"key": "productSlug",
"value": ""
},
{
"key": "userProductId",
"value": ""
},
{
"key": "userProductSlug",
"value": ""
},
{
"key": "blogId",
"value": ""
@@ -1101,6 +1109,133 @@
}
]
},
{
"name": "User Products",
"item": [
{
"name": "List published user products",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"if (pm.response.code === 200) {",
" const json = pm.response.json();",
" if (json.items?.[0]?.id) pm.collectionVariables.set('userProductId', json.items[0].id);",
" if (json.items?.[0]?.slug) pm.collectionVariables.set('userProductSlug', json.items[0].slug);",
"}"
]
}
}
],
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?page=1&pageSize=12",
"host": [
"{{baseUrl}}"
],
"path": [
"tenants",
"{{domain}}",
"user-products"
],
"query": [
{
"key": "page",
"value": "1"
},
{
"key": "pageSize",
"value": "12"
},
{
"key": "name",
"value": "",
"disabled": true
},
{
"key": "q",
"value": "",
"disabled": true
},
{
"key": "categoryId",
"value": "{{categoryId}}",
"disabled": true
},
{
"key": "cityId",
"value": "",
"disabled": true
},
{
"key": "countryId",
"value": "",
"disabled": true
},
{
"key": "condition",
"value": "new",
"disabled": true
},
{
"key": "promoted",
"value": "true",
"disabled": true
}
]
}
}
},
{
"name": "Search user products",
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?q=boiler&page=1&pageSize=12",
"host": [
"{{baseUrl}}"
],
"path": [
"tenants",
"{{domain}}",
"user-products"
],
"query": [
{
"key": "q",
"value": "boiler"
},
{
"key": "page",
"value": "1"
},
{
"key": "pageSize",
"value": "12"
}
]
}
}
},
{
"name": "Get user product by slug",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
}
},
{
"name": "Get user product technical info by slug",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
}
}
]
},
{
"name": "Store Items",
"item": [
+8 -1
View File
@@ -88,10 +88,17 @@
<ol>
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code><code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
</ol>
<h2>User products (customer listings)</h2>
<p>
Marketplace-style stock listings created by customers. Public read-only under
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
See OpenAPI tag <strong>User Products</strong>.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+61
View File
@@ -25,6 +25,7 @@
{ "name": "Homepage" },
{ "name": "Categories" },
{ "name": "Products" },
{ "name": "User Products" },
{ "name": "Store" },
{ "name": "Blogs" },
{ "name": "Portfolios" },
@@ -210,6 +211,66 @@
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/user-products": {
"get": {
"tags": ["User Products"],
"summary": "List published customer / stock listings",
"description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } },
{ "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "cityId", "in": "query", "schema": { "type": "string" } },
{ "name": "countryId", "in": "query", "schema": { "type": "string" } },
{
"name": "condition",
"in": "query",
"schema": {
"type": "string",
"enum": ["new", "stock", "needs_repair", "scrap"]
}
},
{ "name": "promoted", "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
}
}
}
},
"/tenants/{domain}/user-products/{slug}": {
"get": {
"tags": ["User Products"],
"summary": "User product details by slug",
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug"
},
"404": { "description": "Not found or not published" }
}
}
},
"/tenants/{domain}/user-products/{slug}/technical-info": {
"get": {
"tags": ["User Products"],
"summary": "User product technical form + values",
"description": "Category technical form schema plus the listings submitted values (same shape as dashboard technical values).",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/store-items": {
"get": {
"tags": ["Store"],
+116 -11
View File
@@ -14,16 +14,17 @@ model User {
email String? @db.VarChar(255)
firstName String? @map("first_name") @db.VarChar(100)
lastName String? @map("last_name") @db.VarChar(100)
firstNameEn String? @map("first_name_en") @db.VarChar(100)
lastNameEn String? @map("last_name_en") @db.VarChar(100)
isActive Boolean @default(true) @map("is_active")
cellVerifiedAt DateTime? @map("cell_verified_at") @db.Timestamptz(6)
lastLoginAt DateTime? @map("last_login_at") @db.Timestamptz(6)
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
profile Json @default("{}")
firstNameEn String? @map("first_name_en") @db.VarChar(100)
lastNameEn String? @map("last_name_en") @db.VarChar(100)
oldId BigInt? @map("old_id")
addresses Address[]
authoredBlogs blogs[] @relation("BlogAuthor")
businessCustomers BusinessCustomer[]
businessUsersInvited BusinessUser[] @relation("BusinessInviter")
businessUsers BusinessUser[] @relation("BusinessMember")
@@ -35,13 +36,13 @@ model User {
mediaUploaded Media[]
ordersCreated Order[] @relation("OrderCreator")
orders Order[] @relation("OrderCustomer")
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
shoppingCardsCreated ShoppingCard[] @relation("ShoppingCardCreator")
shoppingCards ShoppingCard[] @relation("ShoppingCardCustomer")
transactionsCreated Transaction[] @relation("TransactionCreator")
transactions Transaction[] @relation("TransactionCustomer")
userProducts UserProduct[]
userRoles UserRole[]
authoredBlogs blogs[] @relation("BlogAuthor")
authoredPortfolios portfolios[] @relation("PortfolioAuthor")
@@index([cellNumber], map: "idx_users_cell_number")
@@map("users")
@@ -98,6 +99,8 @@ model Business {
storeItems StoreItem[]
storeSpecials StoreSpecial[]
transactions Transaction[]
userProductTechnicalFieldValues UserProductTechnicalFieldValue[]
userProducts UserProduct[]
website_brand_groups website_brand_groups[]
website_category_groups website_category_groups[]
website_sliders website_sliders[]
@@ -280,6 +283,7 @@ model Media {
attachments MediaAttachment[]
portfolios portfolios[]
featuredProducts Product[] @relation("ProductFeaturedMedia")
featuredUserProducts UserProduct[] @relation("UserProductFeaturedMedia")
website_slider_slides website_slider_slides[]
@@index([businessId], map: "idx_media_business_id")
@@ -385,6 +389,7 @@ model Brand {
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
imageMedia Media? @relation(fields: [imageMediaId], references: [id], onUpdate: NoAction)
products Product[]
userProducts UserProduct[]
website_brand_group_items website_brand_group_items[]
@@unique([businessId, slug], map: "brands_business_slug_unique")
@@ -489,6 +494,7 @@ model CategoryTechnicalFormField {
options CategoryTechnicalFormFieldOption[]
form CategoryTechnicalForm @relation(fields: [formId], references: [id], onDelete: Cascade, onUpdate: NoAction)
values ProductTechnicalFieldValue[]
userProductFieldValues UserProductTechnicalFieldValue[]
@@unique([formId, fieldKey], map: "category_technical_form_fields_unique_key")
@@index([formId], map: "idx_category_technical_form_fields_form_id")
@@ -505,6 +511,8 @@ model CategoryTechnicalFormFieldOption {
field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction)
multiSelectValues ProductTechnicalFieldValueOption[]
selectedValues ProductTechnicalFieldValue[]
userProductMultiSelectValues UserProductTechnicalFieldValueOption[]
userProductSelectedValues UserProductTechnicalFieldValue[]
@@unique([fieldId, value], map: "category_technical_form_field_options_unique_value")
@@index([fieldId], map: "idx_category_technical_form_field_options_field_id")
@@ -634,10 +642,7 @@ model blogs {
model portfolios {
id BigInt @id @default(autoincrement())
business_id BigInt
author_id BigInt?
title String @db.VarChar(255)
title_fa String? @db.VarChar(255)
title_en String? @db.VarChar(255)
slug String @db.VarChar(255)
description String?
content Json @default("{}")
@@ -651,7 +656,10 @@ model portfolios {
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
old_id BigInt?
author User? @relation("PortfolioAuthor", fields: [author_id], references: [id], onUpdate: NoAction, onDelete: SetNull)
title_fa String? @db.VarChar(255)
title_en String? @db.VarChar(255)
author_id BigInt?
author User? @relation("PortfolioAuthor", fields: [author_id], references: [id], onUpdate: NoAction)
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
media Media? @relation(fields: [featured_media_id], references: [id], onUpdate: NoAction)
@@ -697,6 +705,9 @@ model City {
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction)
children City[] @relation("CityTree")
userProductsAsCity UserProduct[] @relation("UserProductCity")
userProductsAsCountry UserProduct[] @relation("UserProductCountry")
userProductsAsDistrict UserProduct[] @relation("UserProductDistrict")
@@index([level], map: "idx_cities_level")
@@index([level, parentId, sortOrder], map: "idx_cities_level_parent")
@@ -959,11 +970,11 @@ model StoreSpecial {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
title String @db.VarChar(255)
key String @db.VarChar(100)
sortOrder Int @default(0) @map("sort_order")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
key String @db.VarChar(100)
items StoreSpecialItem[]
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@ -989,11 +1000,11 @@ model website_brand_groups {
id BigInt @id @default(autoincrement())
business_id BigInt
title String @db.VarChar(255)
key String @db.VarChar(100)
sort_order Int @default(0)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
key String @db.VarChar(100)
website_brand_group_items website_brand_group_items[]
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@ -1018,11 +1029,11 @@ model website_category_groups {
id BigInt @id @default(autoincrement())
business_id BigInt
title String @db.VarChar(255)
key String @db.VarChar(100)
sort_order Int @default(0)
is_active Boolean @default(true)
created_at DateTime @default(now()) @db.Timestamptz(6)
updated_at DateTime @default(now()) @db.Timestamptz(6)
key String @db.VarChar(100)
website_category_group_items website_category_group_items[]
businesses Business @relation(fields: [business_id], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@ -1230,6 +1241,88 @@ model InvoiceAccount {
@@map("invoice_accounts")
}
model UserProductTechnicalFieldValueOption {
fieldValueId BigInt @map("field_value_id")
optionId BigInt @map("option_id")
fieldValue UserProductTechnicalFieldValue @relation(fields: [fieldValueId], references: [id], onDelete: Cascade, onUpdate: NoAction)
option CategoryTechnicalFormFieldOption @relation(fields: [optionId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@id([fieldValueId, optionId])
@@index([optionId], map: "idx_user_product_technical_field_value_options_option_id")
@@map("user_product_technical_field_value_options")
}
model UserProductTechnicalFieldValue {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
userProductId BigInt @map("user_product_id")
fieldId BigInt @map("field_id")
textValue String? @map("text_value")
optionId BigInt? @map("option_id")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
selectedOptions UserProductTechnicalFieldValueOption[]
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction)
option CategoryTechnicalFormFieldOption? @relation(fields: [optionId], references: [id], onUpdate: NoAction)
userProduct UserProduct @relation(fields: [userProductId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([userProductId, fieldId], map: "user_product_technical_field_values_unique")
@@index([businessId], map: "idx_user_product_technical_field_values_business_id")
@@index([userProductId], map: "idx_user_product_technical_field_values_user_product_id")
@@map("user_product_technical_field_values")
}
/// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info.
model UserProduct {
id BigInt @id @default(autoincrement())
businessId BigInt @map("business_id")
userId BigInt @map("user_id")
title String @db.VarChar(255)
slug String @db.VarChar(255)
description String?
content Json @default("{}")
price Decimal? @db.Decimal(12, 2)
compareAtPrice Decimal? @map("compare_at_price") @db.Decimal(12, 2)
sku String? @db.VarChar(100)
stockQuantity Int? @map("stock_quantity")
status ContentStatus @default(draft)
featuredMediaId BigInt? @map("featured_media_id")
brandId BigInt? @map("brand_id")
countryId BigInt @map("country_id")
cityId BigInt @map("city_id")
districtId BigInt? @map("district_id")
sortOrder Int @default(0) @map("sort_order")
publishedAt DateTime? @map("published_at") @db.Timestamptz(6)
metadata Json @default("{}")
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
priceCurrency String @default("IRT") @map("price_currency") @db.VarChar(8)
deliveryNote String? @map("delivery_note")
condition UserProductCondition @default(new)
technicalNotes String? @map("technical_notes")
technicalFieldValues UserProductTechnicalFieldValue[]
brand Brand? @relation(fields: [brandId], references: [id], onUpdate: NoAction)
business Business @relation(fields: [businessId], references: [id], onDelete: Cascade, onUpdate: NoAction)
city City @relation("UserProductCity", fields: [cityId], references: [id], onUpdate: NoAction)
country City @relation("UserProductCountry", fields: [countryId], references: [id], onUpdate: NoAction)
district City? @relation("UserProductDistrict", fields: [districtId], references: [id], onUpdate: NoAction)
featuredMedia Media? @relation("UserProductFeaturedMedia", fields: [featuredMediaId], references: [id], onUpdate: NoAction)
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: NoAction)
@@unique([businessId, slug], map: "user_products_business_slug_unique")
@@index([brandId], map: "idx_user_products_brand_id")
@@index([businessId], map: "idx_user_products_business_id")
@@index([businessId, publishedAt(sort: Desc)], map: "idx_user_products_business_published")
@@index([businessId, status], map: "idx_user_products_business_status")
@@index([businessId, userId], map: "idx_user_products_business_user")
@@index([cityId], map: "idx_user_products_city_id")
@@index([countryId], map: "idx_user_products_country_id")
@@index([districtId], map: "idx_user_products_district_id")
@@index([userId], map: "idx_user_products_user_id")
@@map("user_products")
}
enum MediaType {
image
video
@@ -1242,6 +1335,7 @@ enum MediaEntityType {
blog
portfolio
customer
user_product
@@map("media_entity_type")
}
@@ -1258,6 +1352,7 @@ enum ContentStatus {
draft
published
archived
rejected
@@map("content_status")
}
@@ -1283,6 +1378,7 @@ enum CityLevel {
country
province
city
district
@@map("city_level")
}
@@ -1339,3 +1435,12 @@ enum InvoiceStatus {
@@map("invoice_status")
}
enum UserProductCondition {
new
stock
needs_repair
scrap
@@map("user_product_condition")
}
+2
View File
@@ -28,6 +28,7 @@ import { CustomersModule } from './customers/customers.module';
import { ShoppingCardsModule } from './shopping-cards/shopping-cards.module';
import { ContactSubmissionsModule } from './contact-submissions/contact-submissions.module';
import { FavoritesModule } from './favorites/favorites.module';
import { UserProductsModule } from './user-products/user-products.module';
import { BrandsModule } from './brands/brands.module';
import { WebsiteModule } from './website/website.module';
import { InternalSslModule } from './internal-ssl/internal-ssl.module';
@@ -69,6 +70,7 @@ import { PublicSmsModule } from './public-sms/public-sms.module';
ShoppingCardsModule,
ContactSubmissionsModule,
FavoritesModule,
UserProductsModule,
BrandsModule,
WebsiteModule,
WebsiteDocsModule,
@@ -25,12 +25,14 @@ import { normalizeBusinessPrimaryColorId } from '../business-settings/business-p
import type { BusinessPrimaryColorId } from '../business-settings/business-primary-colors';
import {
normalizeDashboardLocale,
normalizeDashboardThemeMode,
normalizeEnabledBusinessModules,
normalizeHomeCharts,
} from '../business-settings/business-settings.util';
import type {
BusinessModuleId,
DashboardLocale,
DashboardThemeMode,
HomeChartId,
} from '../business-settings/business-settings.types';
import {
@@ -64,6 +66,7 @@ type BusinessRow = {
ownerCellNumber: string | null;
primaryColor: string | null;
defaultLocale: string | null;
themeMode: string | null;
enabledModules: unknown;
homeCharts: unknown;
};
@@ -145,6 +148,7 @@ export class BusinessAdminService {
own."ownerCellNumber" AS "ownerCellNumber",
b.settings->'branding'->>'primaryColor' AS "primaryColor",
b.settings->'branding'->>'defaultLocale' AS "defaultLocale",
b.settings->'branding'->>'themeMode' AS "themeMode",
b.settings->'modules'->'enabled' AS "enabledModules",
b.settings->'modules'->'charts' AS "homeCharts"
FROM businesses b
@@ -201,6 +205,9 @@ export class BusinessAdminService {
defaultLocale: normalizeDashboardLocale(
item.defaultLocale,
) as DashboardLocale,
themeMode: normalizeDashboardThemeMode(
item.themeMode,
) as DashboardThemeMode,
enabledModules,
moduleCount: enabledModules.length,
homeCharts,
@@ -78,6 +78,7 @@ export class BusinessSettingsService {
),
defaultLocale:
dto.branding.defaultLocale ?? current.branding.defaultLocale,
themeMode: dto.branding.themeMode ?? current.branding.themeMode,
};
}
@@ -5,10 +5,16 @@ export type DashboardLocale = 'en' | 'fa';
export const DEFAULT_BUSINESS_DASHBOARD_LOCALE: DashboardLocale = 'fa';
export type DashboardThemeMode = 'light' | 'dark';
export const DEFAULT_BUSINESS_THEME_MODE: DashboardThemeMode = 'light';
export type BrandingSettings = {
primaryColor: BusinessPrimaryColorId;
/** Default UI language for business + customer dashboards. */
defaultLocale: DashboardLocale;
/** Dashboard surface theme (neutral light/dark). Independent of primary brand color. */
themeMode: DashboardThemeMode;
};
export type DashboardCommentsSettings = {
@@ -38,8 +44,8 @@ export type StoreSettings = {
orderProcessSteps: OrderProcessStep[];
};
/** Optional CMS modules a business can have. Always-on areas (customers, website, etc.) are not listed. */
export const BUSINESS_MODULE_IDS = [
/** Optional business-dashboard CMS modules. Always-on areas (customers, website, etc.) are not listed. */
export const BUSINESS_DASHBOARD_MODULE_IDS = [
'products',
'store',
'portfolio',
@@ -48,6 +54,18 @@ export const BUSINESS_MODULE_IDS = [
'videos',
] as const;
/** Optional customer-dashboard modules. Always-on: home, profile, addresses, orders, favorites. */
export const CUSTOMER_MODULE_IDS = ['customer_products'] as const;
/** All optional modules stored in `settings.modules.enabled`. */
export const BUSINESS_MODULE_IDS = [
...BUSINESS_DASHBOARD_MODULE_IDS,
...CUSTOMER_MODULE_IDS,
] as const;
export type BusinessDashboardModuleId =
(typeof BUSINESS_DASHBOARD_MODULE_IDS)[number];
export type CustomerModuleId = (typeof CUSTOMER_MODULE_IDS)[number];
export type BusinessModuleId = (typeof BUSINESS_MODULE_IDS)[number];
/** Home dashboard chart types (super-admin selectable). */
@@ -102,9 +120,12 @@ export const DEFAULT_ORDER_PROCESS_STEPS: OrderProcessStep[] = [
},
];
/** Existing tenants without `settings.modules` keep every module enabled. */
/**
* Existing tenants without `settings.modules` keep every business-dashboard module
* enabled. Customer modules stay opt-in.
*/
export const DEFAULT_ENABLED_BUSINESS_MODULES: BusinessModuleId[] = [
...BUSINESS_MODULE_IDS,
...BUSINESS_DASHBOARD_MODULE_IDS,
];
export const DEFAULT_HOME_CHARTS: [HomeChartId, HomeChartId] = [
@@ -116,6 +137,7 @@ export const DEFAULT_BUSINESS_SETTINGS: BusinessSettings = {
branding: {
primaryColor: DEFAULT_BUSINESS_PRIMARY_COLOR_ID,
defaultLocale: DEFAULT_BUSINESS_DASHBOARD_LOCALE,
themeMode: DEFAULT_BUSINESS_THEME_MODE,
},
dashboard: {
comments: { autoApprove: false },
@@ -8,11 +8,13 @@ import {
BusinessSettings,
DEFAULT_BUSINESS_DASHBOARD_LOCALE,
DEFAULT_BUSINESS_SETTINGS,
DEFAULT_BUSINESS_THEME_MODE,
DEFAULT_ENABLED_BUSINESS_MODULES,
DEFAULT_HOME_CHARTS,
DEFAULT_ORDER_PROCESS_STEPS,
HOME_CHART_IDS,
type DashboardLocale,
type DashboardThemeMode,
type HomeChartId,
OrderProcessStep,
} from './business-settings.types';
@@ -68,6 +70,12 @@ export function normalizeDashboardLocale(value: unknown): DashboardLocale {
return value === 'en' || value === 'fa' ? value : DEFAULT_BUSINESS_DASHBOARD_LOCALE;
}
export function normalizeDashboardThemeMode(value: unknown): DashboardThemeMode {
return value === 'dark' || value === 'light'
? value
: DEFAULT_BUSINESS_THEME_MODE;
}
function readBoolean(value: unknown, fallback: boolean) {
return typeof value === 'boolean' ? value : fallback;
}
@@ -122,6 +130,7 @@ export function normalizeBusinessSettings(raw: unknown): BusinessSettings {
branding: {
primaryColor: normalizeBusinessPrimaryColorId(branding.primaryColor),
defaultLocale: normalizeDashboardLocale(branding.defaultLocale),
themeMode: normalizeDashboardThemeMode(branding.themeMode),
},
dashboard: {
comments: {
@@ -166,6 +175,7 @@ export function mergeBusinessSettings(
patch.branding?.primaryColor ?? current.branding.primaryColor,
defaultLocale:
patch.branding?.defaultLocale ?? current.branding.defaultLocale,
themeMode: patch.branding?.themeMode ?? current.branding.themeMode,
},
dashboard: {
comments: {
@@ -27,6 +27,11 @@ class BrandingSettingsDto {
@IsString()
@IsIn(['en', 'fa'])
defaultLocale?: 'en' | 'fa';
@IsOptional()
@IsString()
@IsIn(['light', 'dark'])
themeMode?: 'light' | 'dark';
}
class DashboardCommentsSettingsDto {
@@ -14,13 +14,43 @@ import {
} from './dto/category-technical-form.dto';
function slugifyKey(value: string): string {
return (
value
const ascii = value
.toLowerCase()
.trim()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '') || 'field'
);
.replace(/^-+|-+$/g, '');
if (ascii) {
return ascii;
}
// Keep letters/numbers from any script (e.g. Farsi labels) when ASCII strip is empty
const unicode = value
.trim()
.toLowerCase()
.normalize('NFKC')
.replace(/\s+/g, '-')
.replace(/[^\p{L}\p{N}-]+/gu, '')
.replace(/-+/g, '-')
.replace(/^-+|-+$/g, '');
return unicode || 'field';
}
function uniqueSlug(base: string, used: Set<string>): string {
let value = base || 'field';
if (!used.has(value)) {
used.add(value);
return value;
}
let suffix = 2;
while (used.has(`${value}-${suffix}`)) {
suffix += 1;
}
const next = `${value}-${suffix}`;
used.add(next);
return next;
}
@Injectable()
@@ -88,15 +118,7 @@ export class CategoryTechnicalFormService {
const usedKeys = new Set<string>();
for (const [index, field] of dto.fields.entries()) {
let fieldKey = slugifyKey(field.label);
if (usedKeys.has(fieldKey)) {
let suffix = 2;
while (usedKeys.has(`${fieldKey}-${suffix}`)) {
suffix += 1;
}
fieldKey = `${fieldKey}-${suffix}`;
}
usedKeys.add(fieldKey);
const fieldKey = uniqueSlug(slugifyKey(field.label), usedKeys);
const createdField = await tx.categoryTechnicalFormField.create({
data: {
@@ -113,12 +135,13 @@ export class CategoryTechnicalFormService {
const uniqueOptions = [
...new Set(field.options!.map((o) => o.trim()).filter(Boolean)),
];
const usedValues = new Set<string>();
await tx.categoryTechnicalFormFieldOption.createMany({
data: uniqueOptions.map((label, optionIndex) => ({
fieldId: createdField.id,
label,
value: slugifyKey(label) || `option-${optionIndex + 1}`,
value: uniqueSlug(slugifyKey(label), usedValues),
sortOrder: optionIndex,
})),
});
+35 -9
View File
@@ -26,25 +26,51 @@ export class CitiesService {
...(query.level ? { level: query.level } : {}),
};
if (query.parentId) {
where.parentId = BigInt(query.parentId);
} else if (query.parentSlug) {
const parent = await this.prisma.city.findFirst({
where: { slug: query.parentSlug, isActive: true },
select: { id: true },
});
let parent:
| { id: bigint; level: CityLevel }
| null = null;
if (query.parentId) {
parent = await this.prisma.city.findFirst({
where: { id: BigInt(query.parentId), isActive: true },
select: { id: true, level: true },
});
if (!parent) {
return { items: [] };
}
} else if (query.parentSlug) {
parent = await this.prisma.city.findFirst({
where: { slug: query.parentSlug, isActive: true },
select: { id: true, level: true },
});
if (!parent) {
return { items: [] };
}
where.parentId = parent.id;
} else if (query.level === CityLevel.province || query.level === CityLevel.city) {
throw new BadRequestException('parentId or parentSlug is required for this level');
} else {
where.level = CityLevel.country;
}
if (parent) {
// Cities under a country: direct children + cities under that country's provinces.
if (query.level === CityLevel.city && parent.level === CityLevel.country) {
const provinces = await this.prisma.city.findMany({
where: {
parentId: parent.id,
level: CityLevel.province,
isActive: true,
},
select: { id: true },
});
where.parentId = {
in: [parent.id, ...provinces.map((item) => item.id)],
};
} else {
where.parentId = parent.id;
}
}
const items = await this.prisma.city.findMany({
where,
orderBy: [{ sortOrder: 'asc' }, { nameEn: 'asc' }],
+1
View File
@@ -7,5 +7,6 @@ import { MediaService } from './media.service';
imports: [AuthModule],
controllers: [MediaController],
providers: [MediaService],
exports: [MediaService],
})
export class MediaModule {}
+48
View File
@@ -99,6 +99,38 @@ export class MediaService {
return { items };
}
/**
* Customer-dashboard uploads for user-product images.
* Requires business-customer membership instead of media.create.
*/
async uploadManyForCustomer(
businessIdRaw: string,
files: Express.Multer.File[],
actor: AuthUser,
) {
const businessId = BigInt(businessIdRaw);
await this.assertBusinessCustomer(businessId, actor.id);
if (!files.length) {
throw new BadRequestException('At least one file is required');
}
const business = await this.prisma.business.findUnique({
where: { id: businessId },
});
if (!business?.isActive) {
throw new NotFoundException('Business not found');
}
const items = [];
for (const file of files) {
items.push(await this.uploadOne(businessId, file, actor.id));
}
return { items };
}
async update(
businessIdRaw: string,
mediaIdRaw: string,
@@ -371,4 +403,20 @@ export class MediaService {
throw new ForbiddenException('You cannot delete media for this business');
}
}
private async assertBusinessCustomer(businessId: bigint, userId: bigint) {
if (await this.permissions.isSuperAdmin(userId)) {
return;
}
const membership = await this.prisma.businessCustomer.findUnique({
where: {
businessId_userId: { businessId, userId },
},
});
if (!membership) {
throw new ForbiddenException('You are not a customer of this business');
}
}
}
+1
View File
@@ -56,6 +56,7 @@ export class TenantService {
domain: normalizedHost,
primaryColor: settings.branding.primaryColor,
defaultLocale: settings.branding.defaultLocale,
themeMode: settings.branding.themeMode,
enabledModules: settings.modules.enabled,
homeCharts: settings.modules.charts,
logoUrl: media?.logoMedia?.publicUrl ?? null,
+176
View File
@@ -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);
}
}
+20
View File
@@ -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
+8 -1
View File
@@ -31,10 +31,17 @@ You are building a **Meshkee business website (storefront)**. You must use the M
### Typical bootstrap sequence
1. `GET /tenants/{domain}` → branding + `businessId`
2. Homepage: business-info, sliders, category-groups, brand-groups, store-specials
3. Catalog: categories, products, store-items
3. Catalog: categories, products, store-items, **user-products** (customer stock listings)
4. Auth: register/login → store tokens. Optional: `POST /auth/send-otp` then `POST /auth/login-otp` (passwordless) or `POST /auth/reset-password` (forgot password). `POST /auth/verify-otp` only marks the cell verified (no tokens).
5. Cart checkout with `addressId` or inline `shippingAddress` + `payment`
### User products (customer listings)
Public marketplace listings owned by customers — not catalog `products`.
- `GET /tenants/{domain}/user-products` — list published (`name`/`q`, `categoryId`, `cityId`, `countryId`, `condition`, `promoted`, pagination)
- `GET /tenants/{domain}/user-products/{slug}` — details + gallery
- `GET /tenants/{domain}/user-products/{slug}/technical-info` — category form + values
Use product categories from `GET /tenants/{domain}/categories?entityType=product` for filters. Creating/editing listings is customer-dashboard only (`/businesses/.../my-user-products`), not website-facing.
If OpenAPI and this brief conflict, **OpenAPI wins**.
---
@@ -33,6 +33,14 @@
"key": "productSlug",
"value": ""
},
{
"key": "userProductId",
"value": ""
},
{
"key": "userProductSlug",
"value": ""
},
{
"key": "blogId",
"value": ""
@@ -1101,6 +1109,133 @@
}
]
},
{
"name": "User Products",
"item": [
{
"name": "List published user products",
"event": [
{
"listen": "test",
"script": {
"type": "text/javascript",
"exec": [
"if (pm.response.code === 200) {",
" const json = pm.response.json();",
" if (json.items?.[0]?.id) pm.collectionVariables.set('userProductId', json.items[0].id);",
" if (json.items?.[0]?.slug) pm.collectionVariables.set('userProductSlug', json.items[0].slug);",
"}"
]
}
}
],
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?page=1&pageSize=12",
"host": [
"{{baseUrl}}"
],
"path": [
"tenants",
"{{domain}}",
"user-products"
],
"query": [
{
"key": "page",
"value": "1"
},
{
"key": "pageSize",
"value": "12"
},
{
"key": "name",
"value": "",
"disabled": true
},
{
"key": "q",
"value": "",
"disabled": true
},
{
"key": "categoryId",
"value": "{{categoryId}}",
"disabled": true
},
{
"key": "cityId",
"value": "",
"disabled": true
},
{
"key": "countryId",
"value": "",
"disabled": true
},
{
"key": "condition",
"value": "new",
"disabled": true
},
{
"key": "promoted",
"value": "true",
"disabled": true
}
]
}
}
},
{
"name": "Search user products",
"request": {
"method": "GET",
"url": {
"raw": "{{baseUrl}}/tenants/{{domain}}/user-products?q=boiler&page=1&pageSize=12",
"host": [
"{{baseUrl}}"
],
"path": [
"tenants",
"{{domain}}",
"user-products"
],
"query": [
{
"key": "q",
"value": "boiler"
},
{
"key": "page",
"value": "1"
},
{
"key": "pageSize",
"value": "12"
}
]
}
}
},
{
"name": "Get user product by slug",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}"
}
},
{
"name": "Get user product technical info by slug",
"request": {
"method": "GET",
"url": "{{baseUrl}}/tenants/{{domain}}/user-products/{{userProductSlug}}/technical-info"
}
}
]
},
{
"name": "Store Items",
"item": [
+8 -1
View File
@@ -88,10 +88,17 @@
<ol>
<li>Variable <code>domain</code> = website apex only (no <code>www</code>/<code>api</code>/<code>customer</code>/<code>business</code>).</li>
<li><code>GET /tenants/{domain}</code><code>businessId</code>.</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth).</li>
<li>Public pages: <code>/tenants/{domain}/...</code> (no auth) — products, <strong>user-products</strong>, blogs, portfolios, store-items, etc.</li>
<li>Cart / orders / favorites: <code>/businesses/{businessId}/...</code> + Bearer JWT.</li>
</ol>
<h2>User products (customer listings)</h2>
<p>
Marketplace-style stock listings created by customers. Public read-only under
<code>/tenants/{domain}/user-products</code> (list / details / technical-info).
See OpenAPI tag <strong>User Products</strong>.
</p>
<h2>For a new website AI / designer</h2>
<ol>
<li>Open <a href="/docs/website/AI_PROMPT.md">AI_PROMPT.md</a> and paste it into the AI chat.</li>
+61
View File
@@ -25,6 +25,7 @@
{ "name": "Homepage" },
{ "name": "Categories" },
{ "name": "Products" },
{ "name": "User Products" },
{ "name": "Store" },
{ "name": "Blogs" },
{ "name": "Portfolios" },
@@ -210,6 +211,66 @@
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/user-products": {
"get": {
"tags": ["User Products"],
"summary": "List published customer / stock listings",
"description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } },
{ "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } },
{ "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } },
{ "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } },
{ "name": "categoryId", "in": "query", "schema": { "type": "string" } },
{ "name": "cityId", "in": "query", "schema": { "type": "string" } },
{ "name": "countryId", "in": "query", "schema": { "type": "string" } },
{
"name": "condition",
"in": "query",
"schema": {
"type": "string",
"enum": ["new", "stock", "needs_repair", "scrap"]
}
},
{ "name": "promoted", "in": "query", "schema": { "type": "boolean" } }
],
"responses": {
"200": {
"description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt."
}
}
}
},
"/tenants/{domain}/user-products/{slug}": {
"get": {
"tags": ["User Products"],
"summary": "User product details by slug",
"description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": {
"200": {
"description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug"
},
"404": { "description": "Not found or not published" }
}
}
},
"/tenants/{domain}/user-products/{slug}/technical-info": {
"get": {
"tags": ["User Products"],
"summary": "User product technical form + values",
"description": "Category technical form schema plus the listings submitted values (same shape as dashboard technical values).",
"parameters": [
{ "$ref": "#/components/parameters/domain" },
{ "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } }
],
"responses": { "200": { "description": "{ form, values }" } }
}
},
"/tenants/{domain}/store-items": {
"get": {
"tags": ["Store"],