diff --git a/database/migrations/052_user_products.sql b/database/migrations/052_user_products.sql new file mode 100644 index 0000000..a31c3b0 --- /dev/null +++ b/database/migrations/052_user_products.sql @@ -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; diff --git a/database/migrations/053_cities_country_optional_province.sql b/database/migrations/053_cities_country_optional_province.sql new file mode 100644 index 0000000..d05b906 --- /dev/null +++ b/database/migrations/053_cities_country_optional_province.sql @@ -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 +); diff --git a/database/migrations/054_seed_iran_provinces_cities.sql b/database/migrations/054_seed_iran_provinces_cities.sql new file mode 100644 index 0000000..1ca2d5e --- /dev/null +++ b/database/migrations/054_seed_iran_provinces_cities.sql @@ -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)); diff --git a/database/migrations/055_user_products_listing_fields.sql b/database/migrations/055_user_products_listing_fields.sql new file mode 100644 index 0000000..0c6101e --- /dev/null +++ b/database/migrations/055_user_products_listing_fields.sql @@ -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}$'); diff --git a/database/migrations/056_content_status_rejected.sql b/database/migrations/056_content_status_rejected.sql new file mode 100644 index 0000000..916a505 --- /dev/null +++ b/database/migrations/056_content_status_rejected.sql @@ -0,0 +1 @@ +ALTER TYPE content_status ADD VALUE IF NOT EXISTS 'rejected'; diff --git a/docs/PROJECT_CONTEXT.md b/docs/PROJECT_CONTEXT.md index 9b1fcdf..3659352 100644 --- a/docs/PROJECT_CONTEXT.md +++ b/docs/PROJECT_CONTEXT.md @@ -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/` | 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 | --- diff --git a/docs/website-api/AI_PROMPT.md b/docs/website-api/AI_PROMPT.md index 61056e9..ab740a3 100644 --- a/docs/website-api/AI_PROMPT.md +++ b/docs/website-api/AI_PROMPT.md @@ -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**. --- diff --git a/docs/website-api/Meshkee-Website-API.postman_collection.json b/docs/website-api/Meshkee-Website-API.postman_collection.json index 02ed6bb..4827c6f 100644 --- a/docs/website-api/Meshkee-Website-API.postman_collection.json +++ b/docs/website-api/Meshkee-Website-API.postman_collection.json @@ -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": [ diff --git a/docs/website-api/index.html b/docs/website-api/index.html index d49338c..d3e5f38 100644 --- a/docs/website-api/index.html +++ b/docs/website-api/index.html @@ -88,10 +88,17 @@
  1. Variable domain = website apex only (no www/api/customer/business).
  2. GET /tenants/{domain}businessId.
  3. -
  4. Public pages: /tenants/{domain}/... (no auth).
  5. +
  6. Public pages: /tenants/{domain}/... (no auth) — products, user-products, blogs, portfolios, store-items, etc.
  7. Cart / orders / favorites: /businesses/{businessId}/... + Bearer JWT.
+

User products (customer listings)

+

+ Marketplace-style stock listings created by customers. Public read-only under + /tenants/{domain}/user-products (list / details / technical-info). + See OpenAPI tag User Products. +

+

For a new website AI / designer

  1. Open AI_PROMPT.md and paste it into the AI chat.
  2. diff --git a/docs/website-api/openapi.json b/docs/website-api/openapi.json index f94041e..c791420 100644 --- a/docs/website-api/openapi.json +++ b/docs/website-api/openapi.json @@ -25,6 +25,7 @@ { "name": "Homepage" }, { "name": "Categories" }, { "name": "Products" }, + { "name": "User Products" }, { "name": "Store" }, { "name": "Blogs" }, { "name": "Portfolios" }, @@ -210,6 +211,66 @@ "responses": { "200": { "description": "{ form, values }" } } } }, + "/tenants/{domain}/user-products": { + "get": { + "tags": ["User Products"], + "summary": "List published customer / stock listings", + "description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, + { "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } }, + { "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } }, + { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, + { "name": "cityId", "in": "query", "schema": { "type": "string" } }, + { "name": "countryId", "in": "query", "schema": { "type": "string" } }, + { + "name": "condition", + "in": "query", + "schema": { + "type": "string", + "enum": ["new", "stock", "needs_repair", "scrap"] + } + }, + { "name": "promoted", "in": "query", "schema": { "type": "boolean" } } + ], + "responses": { + "200": { + "description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt." + } + } + } + }, + "/tenants/{domain}/user-products/{slug}": { + "get": { + "tags": ["User Products"], + "summary": "User product details by slug", + "description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug" + }, + "404": { "description": "Not found or not published" } + } + } + }, + "/tenants/{domain}/user-products/{slug}/technical-info": { + "get": { + "tags": ["User Products"], + "summary": "User product technical form + values", + "description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "{ form, values }" } } + } + }, "/tenants/{domain}/store-items": { "get": { "tags": ["Store"], diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3a61029..0f9c9c8 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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,72 +36,74 @@ 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") } model Business { - id BigInt @id @default(autoincrement()) - name String @db.VarChar(255) - slug String @unique(map: "businesses_slug_unique") @db.VarChar(100) - description String? - settings Json @default("{}") - 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) - nameFa String? @map("name_fa") @db.VarChar(255) - about String? - vision String? - emails Json @default("[]") - phoneNumbers Json @default("[]") @map("phone_numbers") - socialMedia Json @default("{}") @map("social_media") - logoMediaId BigInt? @map("logo_media_id") - faviconMediaId BigInt? @map("favicon_media_id") - oldBusinessId BigInt? @map("old_business_id") - addresses Address[] - blogs blogs[] - brands Brand[] - categoryAssignments BusinessCategoryAssignment[] - businessCustomers BusinessCustomer[] - businessUsers BusinessUser[] - faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction) - logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction) - carts Cart[] - categories Category[] - contentCategoryAssignments CategoryAssignment[] - categoryTechnicalForms CategoryTechnicalForm[] - categoryVariations CategoryVariation[] - comments Comment[] - contactSubmissions ContactSubmission[] - domains Domain[] - expertReviews ExpertReview[] - favorites Favorite[] - invoiceItemTemplates InvoiceItemTemplate[] - invoiceTemplates InvoiceTemplate[] - invoices Invoice[] @relation("InvoiceBusiness") - invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness") - media Media[] - mediaAttachments MediaAttachment[] - orders Order[] - portfolios portfolios[] - productTechnicalFieldValues ProductTechnicalFieldValue[] - products Product[] - shoppingCards ShoppingCard[] - storeItemVariants StoreItemVariant[] - storeItems StoreItem[] - storeSpecials StoreSpecial[] - transactions Transaction[] - website_brand_groups website_brand_groups[] - website_category_groups website_category_groups[] - website_sliders website_sliders[] + id BigInt @id @default(autoincrement()) + name String @db.VarChar(255) + slug String @unique(map: "businesses_slug_unique") @db.VarChar(100) + description String? + settings Json @default("{}") + 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) + nameFa String? @map("name_fa") @db.VarChar(255) + about String? + vision String? + emails Json @default("[]") + phoneNumbers Json @default("[]") @map("phone_numbers") + socialMedia Json @default("{}") @map("social_media") + logoMediaId BigInt? @map("logo_media_id") + faviconMediaId BigInt? @map("favicon_media_id") + oldBusinessId BigInt? @map("old_business_id") + addresses Address[] + blogs blogs[] + brands Brand[] + categoryAssignments BusinessCategoryAssignment[] + businessCustomers BusinessCustomer[] + businessUsers BusinessUser[] + faviconMedia Media? @relation("BusinessFavicon", fields: [faviconMediaId], references: [id], onUpdate: NoAction) + logoMedia Media? @relation("BusinessLogo", fields: [logoMediaId], references: [id], onUpdate: NoAction) + carts Cart[] + categories Category[] + contentCategoryAssignments CategoryAssignment[] + categoryTechnicalForms CategoryTechnicalForm[] + categoryVariations CategoryVariation[] + comments Comment[] + contactSubmissions ContactSubmission[] + domains Domain[] + expertReviews ExpertReview[] + favorites Favorite[] + invoiceItemTemplates InvoiceItemTemplate[] + invoiceTemplates InvoiceTemplate[] + invoices Invoice[] @relation("InvoiceBusiness") + invoicesIssued Invoice[] @relation("InvoiceIssuerBusiness") + media Media[] + mediaAttachments MediaAttachment[] + orders Order[] + portfolios portfolios[] + productTechnicalFieldValues ProductTechnicalFieldValue[] + products Product[] + shoppingCards ShoppingCard[] + storeItemVariants StoreItemVariant[] + 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[] @@map("businesses") } @@ -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") @@ -306,7 +310,7 @@ model MediaAttachment { @@map("media_attachments") } - model Category { +model Category { id BigInt @id @default(autoincrement()) businessId BigInt @map("business_id") entityType MediaEntityType @map("entity_type") @@ -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") @@ -477,18 +482,19 @@ model CategoryTechnicalForm { } model CategoryTechnicalFormField { - id BigInt @id @default(autoincrement()) - formId BigInt @map("form_id") - label String @db.VarChar(255) - fieldKey String @map("field_key") @db.VarChar(255) - fieldType TechnicalFieldType @map("field_type") - isRequired Boolean @default(false) @map("is_required") - sortOrder Int @default(0) @map("sort_order") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) - options CategoryTechnicalFormFieldOption[] - form CategoryTechnicalForm @relation(fields: [formId], references: [id], onDelete: Cascade, onUpdate: NoAction) - values ProductTechnicalFieldValue[] + id BigInt @id @default(autoincrement()) + formId BigInt @map("form_id") + label String @db.VarChar(255) + fieldKey String @map("field_key") @db.VarChar(255) + fieldType TechnicalFieldType @map("field_type") + isRequired Boolean @default(false) @map("is_required") + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6) + 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") @@ -496,15 +502,17 @@ model CategoryTechnicalFormField { } model CategoryTechnicalFormFieldOption { - id BigInt @id @default(autoincrement()) - fieldId BigInt @map("field_id") - label String @db.VarChar(255) - value String @db.VarChar(255) - sortOrder Int @default(0) @map("sort_order") - createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) - field CategoryTechnicalFormField @relation(fields: [fieldId], references: [id], onDelete: Cascade, onUpdate: NoAction) - multiSelectValues ProductTechnicalFieldValueOption[] - selectedValues ProductTechnicalFieldValue[] + id BigInt @id @default(autoincrement()) + fieldId BigInt @map("field_id") + label String @db.VarChar(255) + value String @db.VarChar(255) + sortOrder Int @default(0) @map("sort_order") + createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6) + 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) @@ -684,19 +692,22 @@ model Address { /// This table contains check constraints and requires additional setup for migrations. Visit https://pris.ly/d/check-constraints for more info. model City { - id BigInt @id @default(autoincrement()) - parentId BigInt? @map("parent_id") - level CityLevel - nameFa String @map("name_fa") @db.VarChar(255) - nameEn String @map("name_en") @db.VarChar(255) - landlineCode String? @map("landline_code") @db.VarChar(10) - slug String @unique(map: "cities_slug_unique") @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) - parent City? @relation("CityTree", fields: [parentId], references: [id], onDelete: Cascade, onUpdate: NoAction) - children City[] @relation("CityTree") + id BigInt @id @default(autoincrement()) + parentId BigInt? @map("parent_id") + level CityLevel + nameFa String @map("name_fa") @db.VarChar(255) + nameEn String @map("name_en") @db.VarChar(255) + landlineCode String? @map("landline_code") @db.VarChar(10) + slug String @unique(map: "cities_slug_unique") @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) + 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") +} diff --git a/src/app.module.ts b/src/app.module.ts index 9b64bde..78ddb89 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -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, diff --git a/src/business-admin/business-admin.service.ts b/src/business-admin/business-admin.service.ts index ce54633..10494af 100644 --- a/src/business-admin/business-admin.service.ts +++ b/src/business-admin/business-admin.service.ts @@ -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, diff --git a/src/business-settings/business-settings.service.ts b/src/business-settings/business-settings.service.ts index ba53347..01351d5 100644 --- a/src/business-settings/business-settings.service.ts +++ b/src/business-settings/business-settings.service.ts @@ -78,6 +78,7 @@ export class BusinessSettingsService { ), defaultLocale: dto.branding.defaultLocale ?? current.branding.defaultLocale, + themeMode: dto.branding.themeMode ?? current.branding.themeMode, }; } diff --git a/src/business-settings/business-settings.types.ts b/src/business-settings/business-settings.types.ts index 0530eb1..01477af 100644 --- a/src/business-settings/business-settings.types.ts +++ b/src/business-settings/business-settings.types.ts @@ -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 }, diff --git a/src/business-settings/business-settings.util.ts b/src/business-settings/business-settings.util.ts index 2bca9a0..2378cad 100644 --- a/src/business-settings/business-settings.util.ts +++ b/src/business-settings/business-settings.util.ts @@ -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: { diff --git a/src/business-settings/dto/update-business-settings.dto.ts b/src/business-settings/dto/update-business-settings.dto.ts index 6ca0b84..3c6b116 100644 --- a/src/business-settings/dto/update-business-settings.dto.ts +++ b/src/business-settings/dto/update-business-settings.dto.ts @@ -27,6 +27,11 @@ class BrandingSettingsDto { @IsString() @IsIn(['en', 'fa']) defaultLocale?: 'en' | 'fa'; + + @IsOptional() + @IsString() + @IsIn(['light', 'dark']) + themeMode?: 'light' | 'dark'; } class DashboardCommentsSettingsDto { diff --git a/src/categories/category-technical-form.service.ts b/src/categories/category-technical-form.service.ts index 2e610f8..1d04499 100644 --- a/src/categories/category-technical-form.service.ts +++ b/src/categories/category-technical-form.service.ts @@ -14,13 +14,43 @@ import { } from './dto/category-technical-form.dto'; function slugifyKey(value: string): string { - return ( - value - .toLowerCase() - .trim() - .replace(/[^a-z0-9]+/g, '-') - .replace(/^-+|-+$/g, '') || 'field' - ); + const ascii = value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .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 { + 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(); 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(); 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, })), }); diff --git a/src/cities/cities.service.ts b/src/cities/cities.service.ts index 52a4cab..1dc3cea 100644 --- a/src/cities/cities.service.ts +++ b/src/cities/cities.service.ts @@ -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' }], diff --git a/src/media/media.module.ts b/src/media/media.module.ts index 4b4336f..8a0eed8 100644 --- a/src/media/media.module.ts +++ b/src/media/media.module.ts @@ -7,5 +7,6 @@ import { MediaService } from './media.service'; imports: [AuthModule], controllers: [MediaController], providers: [MediaService], + exports: [MediaService], }) export class MediaModule {} diff --git a/src/media/media.service.ts b/src/media/media.service.ts index feed556..7e01760 100644 --- a/src/media/media.service.ts +++ b/src/media/media.service.ts @@ -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'); + } + } } diff --git a/src/tenant/tenant.service.ts b/src/tenant/tenant.service.ts index 78f00d8..3f2af85 100644 --- a/src/tenant/tenant.service.ts +++ b/src/tenant/tenant.service.ts @@ -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, diff --git a/src/user-products/dto/user-product.dto.ts b/src/user-products/dto/user-product.dto.ts new file mode 100644 index 0000000..1a9b333 --- /dev/null +++ b/src/user-products/dto/user-product.dto.ts @@ -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; +} diff --git a/src/user-products/user-products.admin.controller.ts b/src/user-products/user-products.admin.controller.ts new file mode 100644 index 0000000..c83ab96 --- /dev/null +++ b/src/user-products/user-products.admin.controller.ts @@ -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); + } +} diff --git a/src/user-products/user-products.controller.ts b/src/user-products/user-products.controller.ts new file mode 100644 index 0000000..446df78 --- /dev/null +++ b/src/user-products/user-products.controller.ts @@ -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); + } +} diff --git a/src/user-products/user-products.module.ts b/src/user-products/user-products.module.ts new file mode 100644 index 0000000..1b9bb8a --- /dev/null +++ b/src/user-products/user-products.module.ts @@ -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 {} diff --git a/src/user-products/user-products.public.controller.ts b/src/user-products/user-products.public.controller.ts new file mode 100644 index 0000000..82d0326 --- /dev/null +++ b/src/user-products/user-products.public.controller.ts @@ -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); + } +} diff --git a/src/user-products/user-products.service.ts b/src/user-products/user-products.service.ts new file mode 100644 index 0000000..1a84099 --- /dev/null +++ b/src/user-products/user-products.service.ts @@ -0,0 +1,1610 @@ +import { + BadRequestException, + ForbiddenException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { + CityLevel, + ContentStatus, + MediaEntityType, + Prisma, + TechnicalFieldType, +} from '@prisma/client'; +import { AuthUser } from '../auth/auth.types'; +import { PermissionsService } from '../auth/permissions.service'; +import { CategoryTechnicalFormService } from '../categories/category-technical-form.service'; +import { MediaService } from '../media/media.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { + CreateUserProductDto, + ListAdminUserProductsDto, + ListMyUserProductsDto, + ListPublicUserProductsDto, + UpdateUserProductDto, + UpdateUserProductStatusDto, + UserProductTechnicalValueDto, +} from './dto/user-product.dto'; +import { TenantService } from '../tenant/tenant.service'; + +function slugify(value: string): string { + return ( + value + .toLowerCase() + .trim() + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-+|-+$/g, '') || 'user-product' + ); +} + +type FormField = { + id: string; + label: string; + key: string; + type: TechnicalFieldType; + isRequired: boolean; + sortOrder: number; + options: { id: string; label: string; value: string; sortOrder: number }[]; +}; + +const userProductListInclude = { + featuredMedia: true, + city: true, + country: true, +} satisfies Prisma.UserProductInclude; + +type UserProductListRow = Prisma.UserProductGetPayload<{ + include: typeof userProductListInclude; +}>; + +const userProductAdminListInclude = { + ...userProductListInclude, + user: { + select: { + id: true, + firstName: true, + lastName: true, + firstNameEn: true, + lastNameEn: true, + cellNumber: true, + }, + }, +} satisfies Prisma.UserProductInclude; + +type UserProductAdminListRow = Prisma.UserProductGetPayload<{ + include: typeof userProductAdminListInclude; +}>; + +const userProductDetailInclude = { + ...userProductListInclude, + technicalFieldValues: { + include: { + selectedOptions: true, + }, + }, +} satisfies Prisma.UserProductInclude; + +type UserProductDetailRow = Prisma.UserProductGetPayload<{ + include: typeof userProductDetailInclude; +}>; + +const userProductAdminDetailInclude = { + ...userProductAdminListInclude, + technicalFieldValues: { + include: { + selectedOptions: true, + }, + }, +} satisfies Prisma.UserProductInclude; + +type UserProductAdminDetailRow = Prisma.UserProductGetPayload<{ + include: typeof userProductAdminDetailInclude; +}>; + +@Injectable() +export class UserProductsService { + constructor( + private readonly prisma: PrismaService, + private readonly permissions: PermissionsService, + private readonly technicalFormService: CategoryTechnicalFormService, + private readonly mediaService: MediaService, + private readonly tenant: TenantService, + ) {} + + async listPublic(host: string, query: ListPublicUserProductsDto) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const page = query.page ?? 1; + const pageSize = Math.min(Math.max(query.pageSize ?? 12, 1), 100); + const skip = (page - 1) * pageSize; + const where = await this.buildPublicWhere(businessId, query); + + const [items, total] = await Promise.all([ + this.prisma.userProduct.findMany({ + where, + orderBy: [ + { publishedAt: 'desc' }, + { createdAt: 'desc' }, + ], + skip, + take: pageSize, + include: userProductListInclude, + }), + this.prisma.userProduct.count({ where }), + ]); + + const categoryByEntity = await this.loadCategoriesForProducts( + businessId, + items.map((item) => item.id), + ); + + const serialized = items.map((item) => + this.serializeListItem(item, categoryByEntity.get(item.id.toString())), + ); + + const promotedOnly = query.promoted === true; + const ordered = promotedOnly + ? serialized + : [ + ...serialized.filter((item) => item.promoted), + ...serialized.filter((item) => !item.promoted), + ]; + + return { + items: ordered, + total, + page, + pageSize, + }; + } + + async getPublicBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const product = await this.prisma.userProduct.findFirst({ + where: { + businessId, + slug, + status: ContentStatus.published, + }, + include: userProductDetailInclude, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + product.id, + ]); + const gallery = await this.loadGalleryAttachments(businessId, product.id); + + return { + product: this.serializeDetail( + product, + categoryByEntity.get(product.id.toString()), + gallery, + ), + }; + } + + async getPublicTechnicalInfoBySlug(host: string, slug: string) { + const business = await this.tenant.resolveBusinessByDomain(host); + const businessId = business.id; + + const product = await this.prisma.userProduct.findFirst({ + where: { + businessId, + slug, + status: ContentStatus.published, + }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + product.id, + ]); + const category = categoryByEntity.get(product.id.toString()); + if (!category) { + return { + form: null, + values: [], + message: 'User product has no category assigned', + }; + } + + const form = await this.technicalFormService.getFormForCategory( + businessId, + category.id, + ); + if (!form) { + return { form: null, values: [] }; + } + + const detail = await this.prisma.userProduct.findFirst({ + where: { id: product.id }, + include: userProductDetailInclude, + }); + if (!detail) { + throw new NotFoundException('User product not found'); + } + + const serialized = this.serializeDetail(detail, category, []); + return { + form, + values: serialized.technicalValues, + }; + } + + async list( + businessIdRaw: string, + query: ListMyUserProductsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const page = query.page ?? 1; + const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100); + const skip = (page - 1) * pageSize; + + const where: Prisma.UserProductWhereInput = { + businessId, + userId: actor.id, + }; + + const [items, total] = await Promise.all([ + this.prisma.userProduct.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: userProductListInclude, + }), + this.prisma.userProduct.count({ where }), + ]); + + const categoryByEntity = await this.loadCategoriesForProducts( + businessId, + items.map((item) => item.id), + ); + + return { + items: items.map((item) => + this.serializeListItem(item, categoryByEntity.get(item.id.toString())), + ), + total, + page, + pageSize, + }; + } + + async create( + businessIdRaw: string, + dto: CreateUserProductDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + return this.createForUser(businessId, dto, actor.id); + } + + private async createForUser( + businessId: bigint, + dto: CreateUserProductDto, + userId: bigint, + ) { + const titleFa = dto.titleFa.trim(); + if (!titleFa) { + throw new BadRequestException('titleFa is required'); + } + + const categoryId = BigInt(dto.categoryId); + await this.assertProductCategory(businessId, categoryId); + + const countryId = BigInt(dto.countryId); + const cityId = BigInt(dto.cityId); + await this.assertCountryAndCity(countryId, cityId); + + let featuredMediaId: bigint | null = null; + if (dto.featuredMediaId) { + featuredMediaId = BigInt(dto.featuredMediaId); + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + + const galleryMediaIds = await this.resolveGalleryMediaIds( + businessId, + dto.galleryMediaIds ?? [], + ); + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + const technicalValues = dto.technicalValues ?? []; + this.validateTechnicalValues(form?.fields ?? [], technicalValues); + + const slug = await this.ensureUniqueSlug(businessId, slugify(titleFa)); + const priceCurrency = dto.priceCurrency ?? 'IRT'; + const content: Prisma.InputJsonValue = { + ...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}), + }; + const metadata: Prisma.InputJsonValue = { + priceByExpert: dto.priceByExpert === true, + }; + + const created = await this.prisma.$transaction(async (tx) => { + const product = await tx.userProduct.create({ + data: { + businessId, + userId, + title: titleFa, + slug, + description: dto.description?.trim() || null, + content, + price: + dto.price === undefined || dto.price === null + ? null + : dto.price, + priceCurrency, + status: ContentStatus.draft, + featuredMediaId, + countryId, + cityId, + deliveryNote: dto.deliveryNote?.trim() || null, + condition: dto.condition, + technicalNotes: dto.technicalNotes?.trim() || null, + metadata, + }, + include: userProductListInclude, + }); + + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId, + entityType: MediaEntityType.user_product, + entityId: product.id, + }, + }); + + await this.syncGalleryAttachments( + tx, + businessId, + product.id, + galleryMediaIds, + ); + + if (form?.fields.length) { + await this.saveTechnicalValues( + tx, + businessId, + product.id, + form.fields, + technicalValues, + ); + } + + return product; + }); + + const category = await this.prisma.category.findFirst({ + where: { id: categoryId, businessId }, + select: { id: true, name: true, nameFa: true }, + }); + + const withUser = await this.prisma.userProduct.findFirst({ + where: { id: created.id }, + include: userProductAdminListInclude, + }); + + return { + message: 'User product created successfully', + product: withUser + ? this.serializeAdminListItem(withUser, category ?? undefined) + : this.serializeListItem(created, category ?? undefined), + }; + } + + async getOne( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const product = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId, userId: actor.id }, + include: userProductDetailInclude, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + product.id, + ]); + const gallery = await this.loadGalleryAttachments(businessId, product.id); + + return { + product: this.serializeDetail( + product, + categoryByEntity.get(product.id.toString()), + gallery, + ), + }; + } + + async update( + businessIdRaw: string, + productIdRaw: string, + dto: UpdateUserProductDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const existing = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId, userId: actor.id }, + select: { id: true, title: true, slug: true, metadata: true }, + }); + + if (!existing) { + throw new NotFoundException('User product not found'); + } + + return this.updateExisting(businessId, productId, dto, existing); + } + + private async updateExisting( + businessId: bigint, + productId: bigint, + dto: UpdateUserProductDto, + existing: { + id: bigint; + title: string; + slug: string; + metadata: unknown; + }, + ) { + const titleFa = dto.titleFa.trim(); + if (!titleFa) { + throw new BadRequestException('titleFa is required'); + } + + const categoryId = BigInt(dto.categoryId); + await this.assertProductCategory(businessId, categoryId); + + const countryId = BigInt(dto.countryId); + const cityId = BigInt(dto.cityId); + await this.assertCountryAndCity(countryId, cityId); + + let featuredMediaId: bigint | null = null; + if (dto.featuredMediaId) { + featuredMediaId = BigInt(dto.featuredMediaId); + await this.assertMediaBelongsToBusiness(businessId, featuredMediaId); + } + + const galleryMediaIds = + dto.galleryMediaIds !== undefined + ? await this.resolveGalleryMediaIds(businessId, dto.galleryMediaIds) + : null; + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + const technicalValues = dto.technicalValues ?? []; + this.validateTechnicalValues(form?.fields ?? [], technicalValues); + + const existingMetadata = this.asRecord(existing.metadata); + const content: Prisma.InputJsonValue = { + ...(dto.titleEn?.trim() ? { titleEn: dto.titleEn.trim() } : {}), + }; + const metadata: Prisma.InputJsonValue = { + ...existingMetadata, + priceByExpert: dto.priceByExpert === true, + }; + + let slug = existing.slug; + if (titleFa !== existing.title) { + slug = await this.ensureUniqueSlug( + businessId, + slugify(titleFa), + productId, + ); + } + + const updated = await this.prisma.$transaction(async (tx) => { + const product = await tx.userProduct.update({ + where: { id: productId }, + data: { + title: titleFa, + slug, + description: dto.description?.trim() || null, + content, + price: + dto.price === undefined || dto.price === null ? null : dto.price, + priceCurrency: dto.priceCurrency ?? 'IRT', + featuredMediaId, + countryId, + cityId, + deliveryNote: dto.deliveryNote?.trim() || null, + condition: dto.condition, + technicalNotes: dto.technicalNotes?.trim() || null, + metadata, + }, + include: userProductAdminListInclude, + }); + + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.user_product, + entityId: productId, + }, + }); + await tx.categoryAssignment.create({ + data: { + businessId, + categoryId, + entityType: MediaEntityType.user_product, + entityId: productId, + }, + }); + + if (galleryMediaIds !== null) { + await this.syncGalleryAttachments( + tx, + businessId, + productId, + galleryMediaIds, + ); + } + + await tx.userProductTechnicalFieldValue.deleteMany({ + where: { userProductId: productId }, + }); + + if (form?.fields.length) { + await this.saveTechnicalValues( + tx, + businessId, + productId, + form.fields, + technicalValues, + ); + } + + return product; + }); + + const category = await this.prisma.category.findFirst({ + where: { id: categoryId, businessId }, + select: { id: true, name: true, nameFa: true }, + }); + + return { + message: 'User product updated successfully', + product: this.serializeAdminListItem(updated, category ?? undefined), + }; + } + + async uploadMedia( + businessIdRaw: string, + files: Express.Multer.File[], + actor: AuthUser, + ) { + return this.mediaService.uploadManyForCustomer(businessIdRaw, files, actor); + } + + async remove( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const product = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId, userId: actor.id }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.user_product, + entityId: productId, + }, + }); + await tx.userProduct.delete({ where: { id: productId } }); + }); + + return { message: 'User product deleted successfully' }; + } + + async promote( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertCustomerAccess(businessId, actor); + + const product = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId, userId: actor.id }, + include: userProductListInclude, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + const metadata = this.asRecord(product.metadata); + const nextMetadata: Prisma.InputJsonValue = { + ...metadata, + promoted: true, + promotedAt: new Date().toISOString(), + }; + + const updated = await this.prisma.userProduct.update({ + where: { id: productId }, + data: { metadata: nextMetadata }, + include: userProductListInclude, + }); + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + updated.id, + ]); + + return { + message: 'User product promoted successfully', + product: this.serializeListItem( + updated, + categoryByEntity.get(updated.id.toString()), + ), + }; + } + + async adminList( + businessIdRaw: string, + query: ListAdminUserProductsDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.read'); + + const page = query.page ?? 1; + const pageSize = Math.min(Math.max(query.pageSize ?? 20, 1), 100); + const skip = (page - 1) * pageSize; + + const where: Prisma.UserProductWhereInput = { + businessId, + ...(query.status ? { status: query.status } : {}), + }; + + const [items, total] = await Promise.all([ + this.prisma.userProduct.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip, + take: pageSize, + include: userProductAdminListInclude, + }), + this.prisma.userProduct.count({ where }), + ]); + + const categoryByEntity = await this.loadCategoriesForProducts( + businessId, + items.map((item) => item.id), + ); + + return { + items: items.map((item) => + this.serializeAdminListItem( + item, + categoryByEntity.get(item.id.toString()), + ), + ), + total, + page, + pageSize, + }; + } + + async adminGetOne( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.read'); + + const product = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId }, + include: userProductAdminDetailInclude, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + product.id, + ]); + const gallery = await this.loadGalleryAttachments(businessId, product.id); + + return { + product: this.serializeAdminDetail( + product, + categoryByEntity.get(product.id.toString()), + gallery, + ), + }; + } + + async adminCreate( + businessIdRaw: string, + dto: CreateUserProductDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.create'); + return this.createForUser(businessId, dto, actor.id); + } + + async adminUpdate( + businessIdRaw: string, + productIdRaw: string, + dto: UpdateUserProductDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.update'); + + const existing = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId }, + select: { id: true, title: true, slug: true, metadata: true, userId: true }, + }); + + if (!existing) { + throw new NotFoundException('User product not found'); + } + + return this.updateExisting(businessId, productId, dto, existing); + } + + async adminUpdateStatus( + businessIdRaw: string, + productIdRaw: string, + dto: UpdateUserProductStatusDto, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.update'); + + const existing = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId }, + select: { id: true, status: true, publishedAt: true }, + }); + + if (!existing) { + throw new NotFoundException('User product not found'); + } + + const status = dto.status; + const publishedAt = + status === ContentStatus.published + ? existing.publishedAt ?? new Date() + : null; + + const updated = await this.prisma.userProduct.update({ + where: { id: productId }, + data: { status, publishedAt }, + include: userProductAdminListInclude, + }); + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + updated.id, + ]); + + return { + message: 'User product status updated successfully', + product: this.serializeAdminListItem( + updated, + categoryByEntity.get(updated.id.toString()), + ), + }; + } + + async adminPromote( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.update'); + + const product = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId }, + include: userProductAdminListInclude, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + const metadata = this.asRecord(product.metadata); + const nextMetadata: Prisma.InputJsonValue = { + ...metadata, + promoted: true, + promotedAt: new Date().toISOString(), + }; + + const updated = await this.prisma.userProduct.update({ + where: { id: productId }, + data: { metadata: nextMetadata }, + include: userProductAdminListInclude, + }); + + const categoryByEntity = await this.loadCategoriesForProducts(businessId, [ + updated.id, + ]); + + return { + message: 'User product promoted successfully', + product: this.serializeAdminListItem( + updated, + categoryByEntity.get(updated.id.toString()), + ), + }; + } + + async adminRemove( + businessIdRaw: string, + productIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const productId = BigInt(productIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.delete'); + + const product = await this.prisma.userProduct.findFirst({ + where: { id: productId, businessId }, + select: { id: true }, + }); + + if (!product) { + throw new NotFoundException('User product not found'); + } + + await this.prisma.$transaction(async (tx) => { + await tx.categoryAssignment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.user_product, + entityId: productId, + }, + }); + await tx.userProduct.delete({ where: { id: productId } }); + }); + + return { message: 'User product deleted successfully' }; + } + + async listCategoriesForAdmin(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.read'); + return this.listProductCategories(businessId); + } + + async getCategoryTechnicalFormForAdmin( + businessIdRaw: string, + categoryIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertAdminAccess(businessId, actor, 'user_products.read'); + await this.assertProductCategory(businessId, categoryId); + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + + return { form }; + } + + async listCategories(businessIdRaw: string, actor: AuthUser) { + const businessId = BigInt(businessIdRaw); + await this.assertCustomerAccess(businessId, actor); + return this.listProductCategories(businessId); + } + + async getCategoryTechnicalForm( + businessIdRaw: string, + categoryIdRaw: string, + actor: AuthUser, + ) { + const businessId = BigInt(businessIdRaw); + const categoryId = BigInt(categoryIdRaw); + await this.assertCustomerAccess(businessId, actor); + await this.assertProductCategory(businessId, categoryId); + + const form = await this.technicalFormService.getFormForCategory( + businessId, + categoryId, + ); + + return { form }; + } + + private validateTechnicalValues( + fields: FormField[], + values: UserProductTechnicalValueDto[], + ) { + if (!fields.length) { + if (values.length) { + throw new BadRequestException( + 'This category has no technical form fields', + ); + } + return; + } + + const fieldMap = new Map(fields.map((field) => [field.id, field])); + const submittedIds = new Set(); + + for (const entry of values) { + if (submittedIds.has(entry.fieldId)) { + throw new BadRequestException( + `Duplicate technical value for field "${entry.fieldId}"`, + ); + } + submittedIds.add(entry.fieldId); + + const field = fieldMap.get(entry.fieldId); + if (!field) { + throw new BadRequestException( + `Unknown technical field "${entry.fieldId}"`, + ); + } + + this.validateFieldEntry(field, entry); + } + + for (const field of fields) { + if (!field.isRequired) { + continue; + } + const entry = values.find((item) => item.fieldId === field.id); + if (!entry || !this.hasFieldValue(field, entry)) { + throw new BadRequestException( + `Required field "${field.label}" is missing`, + ); + } + } + } + + private validateFieldEntry( + field: FormField, + entry: UserProductTechnicalValueDto, + ) { + if (field.type === 'text' || field.type === 'textarea') { + if (entry.optionId || entry.optionIds?.length) { + throw new BadRequestException( + `Field "${field.label}" expects a text value`, + ); + } + const text = entry.textValue?.trim() ?? ''; + if (field.isRequired && !text) { + throw new BadRequestException( + `Required field "${field.label}" cannot be empty`, + ); + } + return; + } + + if (field.type === 'select') { + if (entry.textValue || entry.optionIds?.length) { + throw new BadRequestException( + `Field "${field.label}" expects a single optionId`, + ); + } + if (!entry.optionId) { + if (field.isRequired) { + throw new BadRequestException( + `Required field "${field.label}" cannot be empty`, + ); + } + return; + } + this.findOptionById(field, entry.optionId); + return; + } + + if (entry.textValue || entry.optionId) { + throw new BadRequestException( + `Field "${field.label}" expects optionIds`, + ); + } + const optionIds = entry.optionIds ?? []; + if (!optionIds.length) { + if (field.isRequired) { + throw new BadRequestException( + `Required field "${field.label}" cannot be empty`, + ); + } + return; + } + for (const optionId of optionIds) { + this.findOptionById(field, optionId); + } + } + + private hasFieldValue( + field: FormField, + entry: UserProductTechnicalValueDto, + ): boolean { + if (field.type === 'text' || field.type === 'textarea') { + return Boolean(entry.textValue?.trim()); + } + if (field.type === 'select') { + return Boolean(entry.optionId); + } + return Boolean(entry.optionIds?.length); + } + + private findOptionById(field: FormField, optionId: string) { + const option = field.options.find((item) => item.id === optionId); + if (!option) { + throw new BadRequestException( + `Invalid option "${optionId}" for field "${field.label}"`, + ); + } + return option; + } + + private async saveTechnicalValues( + tx: Prisma.TransactionClient, + businessId: bigint, + userProductId: bigint, + fields: FormField[], + values: UserProductTechnicalValueDto[], + ) { + const fieldMap = new Map(fields.map((field) => [field.id, field])); + + for (const entry of values) { + const field = fieldMap.get(entry.fieldId); + if (!field) { + continue; + } + + if (!this.hasFieldValue(field, entry)) { + continue; + } + + const fieldId = BigInt(field.id); + + if (field.type === 'text' || field.type === 'textarea') { + await tx.userProductTechnicalFieldValue.create({ + data: { + businessId, + userProductId, + fieldId, + textValue: entry.textValue!.trim(), + }, + }); + continue; + } + + if (field.type === 'select') { + await tx.userProductTechnicalFieldValue.create({ + data: { + businessId, + userProductId, + fieldId, + optionId: BigInt(entry.optionId!), + }, + }); + continue; + } + + const fieldValue = await tx.userProductTechnicalFieldValue.create({ + data: { + businessId, + userProductId, + fieldId, + }, + }); + + for (const optionId of entry.optionIds ?? []) { + await tx.userProductTechnicalFieldValueOption.create({ + data: { + fieldValueId: fieldValue.id, + optionId: BigInt(optionId), + }, + }); + } + } + } + + private async loadCategoriesForProducts( + businessId: bigint, + productIds: bigint[], + ) { + const map = new Map< + string, + { id: bigint; name: string; nameFa: string | null } + >(); + + if (!productIds.length) { + return map; + } + + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + entityType: MediaEntityType.user_product, + entityId: { in: productIds }, + }, + include: { + category: { + select: { id: true, name: true, nameFa: true }, + }, + }, + }); + + for (const assignment of assignments) { + map.set(assignment.entityId.toString(), assignment.category); + } + + return map; + } + + private serializeListItem( + product: UserProductListRow, + category?: { id: bigint; name: string; nameFa: string | null } | null, + ) { + const content = this.asRecord(product.content); + const metadata = this.asRecord(product.metadata); + const titleEn = + typeof content.titleEn === 'string' ? content.titleEn : null; + const priceByExpert = metadata.priceByExpert === true; + const promoted = metadata.promoted === true; + + return { + id: product.id.toString(), + slug: product.slug, + title: product.title, + titleFa: product.title, + titleEn, + description: product.description, + price: product.price === null ? null : Number(product.price), + priceCurrency: product.priceCurrency, + priceByExpert, + promoted, + status: product.status, + condition: product.condition, + deliveryNote: product.deliveryNote, + technicalNotes: product.technicalNotes, + cityId: product.cityId.toString(), + countryId: product.countryId.toString(), + cityName: product.city.nameEn, + cityNameFa: product.city.nameFa, + countryName: product.country.nameEn, + countryNameFa: product.country.nameFa, + imageUrl: product.featuredMedia?.publicUrl ?? null, + categoryId: category?.id.toString() ?? null, + categoryName: category?.name ?? null, + categoryNameFa: category?.nameFa ?? null, + createdAt: product.createdAt, + publishedAt: product.publishedAt, + }; + } + + private serializeDetail( + product: UserProductDetailRow, + category?: { id: bigint; name: string; nameFa: string | null } | null, + gallery: { mediaId: bigint; publicUrl: string }[] = [], + ) { + const base = this.serializeListItem( + product as UserProductListRow, + category, + ); + const technicalValues: UserProductTechnicalValueDto[] = []; + + for (const value of product.technicalFieldValues) { + const fieldId = value.fieldId.toString(); + if (value.textValue != null) { + technicalValues.push({ fieldId, textValue: value.textValue }); + continue; + } + if (value.optionId != null) { + technicalValues.push({ + fieldId, + optionId: value.optionId.toString(), + }); + continue; + } + if (value.selectedOptions.length) { + technicalValues.push({ + fieldId, + optionIds: value.selectedOptions.map((item) => + item.optionId.toString(), + ), + }); + } + } + + return { + ...base, + countryId: product.countryId.toString(), + cityId: product.cityId.toString(), + countrySlug: product.country.slug, + featuredMediaId: product.featuredMediaId?.toString() ?? null, + galleryMediaIds: gallery.map((item) => item.mediaId.toString()), + images: gallery.map((item) => ({ + mediaId: item.mediaId.toString(), + url: item.publicUrl, + })), + technicalValues, + }; + } + + private serializeAdminListItem( + product: UserProductAdminListRow, + category?: { id: bigint; name: string; nameFa: string | null } | null, + ) { + const base = this.serializeListItem( + product as UserProductListRow, + category, + ); + const owner = this.formatOwnerNames(product.user); + return { + ...base, + ownerId: product.user.id.toString(), + ownerName: owner.nameEn, + ownerNameFa: owner.nameFa, + ownerCell: product.user.cellNumber, + }; + } + + private serializeAdminDetail( + product: UserProductAdminDetailRow, + category?: { id: bigint; name: string; nameFa: string | null } | null, + gallery: { mediaId: bigint; publicUrl: string }[] = [], + ) { + const detail = this.serializeDetail( + product as UserProductDetailRow, + category, + gallery, + ); + const owner = this.formatOwnerNames(product.user); + return { + ...detail, + ownerId: product.user.id.toString(), + ownerName: owner.nameEn, + ownerNameFa: owner.nameFa, + ownerCell: product.user.cellNumber, + }; + } + + private formatOwnerNames(user: { + firstName: string | null; + lastName: string | null; + firstNameEn: string | null; + lastNameEn: string | null; + cellNumber: string; + }) { + const nameFa = [user.firstName, user.lastName] + .filter(Boolean) + .join(' ') + .trim(); + const nameEn = [user.firstNameEn, user.lastNameEn] + .filter(Boolean) + .join(' ') + .trim(); + const fallback = nameFa || nameEn || user.cellNumber; + return { + nameFa: nameFa || fallback, + nameEn: nameEn || fallback, + }; + } + + private async listProductCategories(businessId: bigint) { + const items = await this.prisma.category.findMany({ + where: { + businessId, + entityType: MediaEntityType.product, + isActive: true, + }, + orderBy: [{ sortOrder: 'asc' }, { name: 'asc' }], + select: { + id: true, + name: true, + nameFa: true, + parentId: true, + }, + }); + + return { + items: items.map((item) => ({ + id: item.id.toString(), + name: item.name, + nameFa: item.nameFa, + parentId: item.parentId?.toString() ?? null, + })), + }; + } + + private asRecord(value: unknown): Record { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as Record; + } + return {}; + } + + private async ensureUniqueSlug( + businessId: bigint, + baseSlug: string, + excludeId?: bigint, + ) { + let slug = baseSlug; + let suffix = 1; + + while (true) { + const existing = await this.prisma.userProduct.findFirst({ + where: { + businessId, + slug, + ...(excludeId ? { id: { not: excludeId } } : {}), + }, + select: { id: true }, + }); + + if (!existing) { + return slug; + } + + suffix += 1; + slug = `${baseSlug}-${suffix}`; + } + } + + private async assertProductCategory(businessId: bigint, categoryId: bigint) { + const category = await this.prisma.category.findFirst({ + where: { + id: categoryId, + businessId, + entityType: MediaEntityType.product, + isActive: true, + }, + }); + + if (!category) { + throw new NotFoundException('Product category not found'); + } + + return category; + } + + private async assertCountryAndCity(countryId: bigint, cityId: bigint) { + const [country, city] = await Promise.all([ + this.prisma.city.findFirst({ + where: { id: countryId, level: CityLevel.country, isActive: true }, + }), + this.prisma.city.findFirst({ + where: { id: cityId, level: CityLevel.city, isActive: true }, + }), + ]); + + if (!country) { + throw new BadRequestException('Country not found'); + } + if (!city) { + throw new BadRequestException('City not found'); + } + + let cursor: { id: bigint; parentId: bigint | null; level: CityLevel } | null = + city; + let belongsToCountry = false; + + while (cursor) { + if (cursor.id === countryId) { + belongsToCountry = true; + break; + } + if (!cursor.parentId) { + break; + } + cursor = await this.prisma.city.findFirst({ + where: { id: cursor.parentId }, + select: { id: true, parentId: true, level: true }, + }); + } + + if (!belongsToCountry) { + throw new BadRequestException('City does not belong to the selected country'); + } + } + + private async buildPublicWhere( + businessId: bigint, + query: ListPublicUserProductsDto, + ): Promise { + let entityIds: bigint[] | undefined; + + if (query.categoryId) { + const assignments = await this.prisma.categoryAssignment.findMany({ + where: { + businessId, + categoryId: BigInt(query.categoryId), + entityType: MediaEntityType.user_product, + }, + select: { entityId: true }, + }); + + entityIds = assignments.map((item) => item.entityId); + if (entityIds.length === 0) { + return { id: { in: [] } }; + } + } + + const search = (query.q ?? query.name)?.trim(); + const and: Prisma.UserProductWhereInput[] = []; + + if (search) { + and.push({ + OR: [ + { title: { contains: search, mode: 'insensitive' } }, + { description: { contains: search, mode: 'insensitive' } }, + ], + }); + } + + if (query.promoted === true) { + and.push({ + metadata: { + path: ['promoted'], + equals: true, + }, + }); + } + + return { + businessId, + status: ContentStatus.published, + ...(entityIds ? { id: { in: entityIds } } : {}), + ...(query.cityId ? { cityId: BigInt(query.cityId) } : {}), + ...(query.countryId ? { countryId: BigInt(query.countryId) } : {}), + ...(query.condition ? { condition: query.condition } : {}), + ...(and.length ? { AND: and } : {}), + }; + } + + private async assertMediaBelongsToBusiness( + businessId: bigint, + mediaId: bigint, + ) { + const media = await this.prisma.media.findFirst({ + where: { id: mediaId, businessId }, + }); + if (!media) { + throw new BadRequestException('Media not found for this business'); + } + } + + private async resolveGalleryMediaIds(businessId: bigint, rawIds: string[]) { + const ids = rawIds.map((id) => BigInt(id)); + for (const mediaId of ids) { + await this.assertMediaBelongsToBusiness(businessId, mediaId); + } + return ids; + } + + private async syncGalleryAttachments( + tx: Prisma.TransactionClient, + businessId: bigint, + productId: bigint, + mediaIds: bigint[], + ) { + await tx.mediaAttachment.deleteMany({ + where: { + businessId, + entityType: MediaEntityType.user_product, + entityId: productId, + isFeatured: false, + }, + }); + + for (const [index, mediaId] of mediaIds.entries()) { + await tx.mediaAttachment.create({ + data: { + businessId, + mediaId, + entityType: MediaEntityType.user_product, + entityId: productId, + sortOrder: index, + isFeatured: false, + }, + }); + } + } + + private async loadGalleryAttachments(businessId: bigint, productId: bigint) { + const attachments = await this.prisma.mediaAttachment.findMany({ + where: { + businessId, + entityType: MediaEntityType.user_product, + entityId: productId, + isFeatured: false, + }, + include: { media: true }, + orderBy: { sortOrder: 'asc' }, + }); + + return attachments.map((item) => ({ + mediaId: item.mediaId, + publicUrl: item.media.publicUrl, + })); + } + + private async assertAdminAccess( + businessId: bigint, + actor: AuthUser, + permission: string, + ) { + if (await this.permissions.isSuperAdmin(actor.id)) { + return; + } + + const allowed = await this.permissions.hasBusinessPermission( + actor.id, + businessId, + permission, + ); + if (!allowed) { + throw new ForbiddenException('Insufficient permissions'); + } + } + + private async assertCustomerAccess(businessId: bigint, actor: AuthUser) { + if (await this.permissions.isSuperAdmin(actor.id)) { + return; + } + + const membership = await this.prisma.businessCustomer.findUnique({ + where: { + businessId_userId: { businessId, userId: actor.id }, + }, + }); + + if (!membership) { + throw new ForbiddenException('You are not a customer of this business'); + } + } +} diff --git a/src/website-docs/static/AI_PROMPT.md b/src/website-docs/static/AI_PROMPT.md index 61056e9..ab740a3 100644 --- a/src/website-docs/static/AI_PROMPT.md +++ b/src/website-docs/static/AI_PROMPT.md @@ -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**. --- diff --git a/src/website-docs/static/Meshkee-Website-API.postman_collection.json b/src/website-docs/static/Meshkee-Website-API.postman_collection.json index 02ed6bb..4827c6f 100644 --- a/src/website-docs/static/Meshkee-Website-API.postman_collection.json +++ b/src/website-docs/static/Meshkee-Website-API.postman_collection.json @@ -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": [ diff --git a/src/website-docs/static/index.html b/src/website-docs/static/index.html index d49338c..d3e5f38 100644 --- a/src/website-docs/static/index.html +++ b/src/website-docs/static/index.html @@ -88,10 +88,17 @@
    1. Variable domain = website apex only (no www/api/customer/business).
    2. GET /tenants/{domain}businessId.
    3. -
    4. Public pages: /tenants/{domain}/... (no auth).
    5. +
    6. Public pages: /tenants/{domain}/... (no auth) — products, user-products, blogs, portfolios, store-items, etc.
    7. Cart / orders / favorites: /businesses/{businessId}/... + Bearer JWT.
    +

    User products (customer listings)

    +

    + Marketplace-style stock listings created by customers. Public read-only under + /tenants/{domain}/user-products (list / details / technical-info). + See OpenAPI tag User Products. +

    +

    For a new website AI / designer

    1. Open AI_PROMPT.md and paste it into the AI chat.
    2. diff --git a/src/website-docs/static/openapi.json b/src/website-docs/static/openapi.json index f94041e..c791420 100644 --- a/src/website-docs/static/openapi.json +++ b/src/website-docs/static/openapi.json @@ -25,6 +25,7 @@ { "name": "Homepage" }, { "name": "Categories" }, { "name": "Products" }, + { "name": "User Products" }, { "name": "Store" }, { "name": "Blogs" }, { "name": "Portfolios" }, @@ -210,6 +211,66 @@ "responses": { "200": { "description": "{ form, values }" } } } }, + "/tenants/{domain}/user-products": { + "get": { + "tags": ["User Products"], + "summary": "List published customer / stock listings", + "description": "Public marketplace-style listings created by customers (user products). Only `status=published`. Search with `name` or `q` (title/description). Filter by category, city, country, condition, or promoted.", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "page", "in": "query", "schema": { "type": "integer", "default": 1 } }, + { "name": "pageSize", "in": "query", "schema": { "type": "integer", "default": 12 } }, + { "name": "name", "in": "query", "description": "Search title/description (alias of q)", "schema": { "type": "string" } }, + { "name": "q", "in": "query", "description": "Search title/description (alias of name)", "schema": { "type": "string" } }, + { "name": "categoryId", "in": "query", "schema": { "type": "string" } }, + { "name": "cityId", "in": "query", "schema": { "type": "string" } }, + { "name": "countryId", "in": "query", "schema": { "type": "string" } }, + { + "name": "condition", + "in": "query", + "schema": { + "type": "string", + "enum": ["new", "stock", "needs_repair", "scrap"] + } + }, + { "name": "promoted", "in": "query", "schema": { "type": "boolean" } } + ], + "responses": { + "200": { + "description": "{ items: UserProductListItem[], total, page, pageSize }. Each item includes id, slug, titleFa/titleEn, price, priceCurrency, condition, city/country names, imageUrl, category*, promoted, publishedAt." + } + } + } + }, + "/tenants/{domain}/user-products/{slug}": { + "get": { + "tags": ["User Products"], + "summary": "User product details by slug", + "description": "Full published listing: location IDs, gallery images (`images`, `galleryMediaIds`), technical field values, delivery/technical notes.", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { + "200": { + "description": "{ product } with gallery (`images`: [{ mediaId, url }]), featuredMediaId, technicalValues, countryId, cityId, countrySlug" + }, + "404": { "description": "Not found or not published" } + } + } + }, + "/tenants/{domain}/user-products/{slug}/technical-info": { + "get": { + "tags": ["User Products"], + "summary": "User product technical form + values", + "description": "Category technical form schema plus the listing’s submitted values (same shape as dashboard technical values).", + "parameters": [ + { "$ref": "#/components/parameters/domain" }, + { "name": "slug", "in": "path", "required": true, "schema": { "type": "string" } } + ], + "responses": { "200": { "description": "{ form, values }" } } + } + }, "/tenants/{domain}/store-items": { "get": { "tags": ["Store"],