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 # 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 |
--- ---
+8 -1
View File
@@ -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": [
+8 -1
View File
@@ -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>
+61
View File
@@ -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 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": { "/tenants/{domain}/store-items": {
"get": { "get": {
"tags": ["Store"], "tags": ["Store"],
+116 -11
View File
@@ -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")
}
+2
View File
@@ -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,
})), })),
}); });
+35 -9
View File
@@ -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' }],
+1
View File
@@ -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 {}
+48
View File
@@ -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');
}
}
} }
+1
View File
@@ -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,
+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 ### 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": [
+8 -1
View File
@@ -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>
+61
View File
@@ -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 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": { "/tenants/{domain}/store-items": {
"get": { "get": {
"tags": ["Store"], "tags": ["Store"],