Update carousels, card hover, and IranBay branding.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
PORT=3001
|
||||
|
||||
POSTGRES_PORT=5433
|
||||
POSTGRES_USER=ariyaway
|
||||
POSTGRES_PASSWORD=ariyaway_secret
|
||||
POSTGRES_DB=ariyaway
|
||||
POSTGRES_CONTAINER=ariyaway-postgres
|
||||
|
||||
DATABASE_URL=postgresql://ariyaway:ariyaway_secret@127.0.0.1:5433/ariyaway
|
||||
|
||||
# Meshkee API — always remote (IranBay backend may be local)
|
||||
MESHKEE_API_URL=https://api.meshkee.com/api/v1
|
||||
# Prefer cell + password (access tokens expire quickly). Optional static token:
|
||||
# MESHKEE_API_TOKEN=
|
||||
MESHKEE_SUPERADMIN_CELL=
|
||||
MESHKEE_SUPERADMIN_PASSWORD=
|
||||
@@ -0,0 +1,8 @@
|
||||
node_modules/
|
||||
dist/
|
||||
uploads/
|
||||
.env
|
||||
.env.*
|
||||
!.env.example
|
||||
*.log
|
||||
.DS_Store
|
||||
@@ -0,0 +1,61 @@
|
||||
# Marketplace API
|
||||
|
||||
NestJS API for the IranBay marketplace gate.
|
||||
|
||||
## Architecture (Meshkee)
|
||||
|
||||
Two different Meshkee “business” concepts:
|
||||
|
||||
1. **IranBay platform tenant** (`iranbay.com`) — auth only. Customer accounts from
|
||||
IranBay login/register are linked here via `business_customers`. Not a Meshkee storefront.
|
||||
Bootstrap: `npm run bootstrap:iranbay` (needs Meshkee superadmin token).
|
||||
|
||||
2. **Producer shops** — each shop that registers on IranBay becomes a **separate** Meshkee
|
||||
business + an IranBay marketplace listing (`POST /businesses/register-shop`).
|
||||
|
||||
Marketplace DB stores **only listing controls**:
|
||||
`meshkee_business_id`, `slug`, `status`, `is_featured`, `sort_weight`.
|
||||
|
||||
Name, categories, and profile content live in **Meshkee**.
|
||||
|
||||
## Setup
|
||||
|
||||
```bash
|
||||
cd backend
|
||||
cp .env.example .env
|
||||
docker compose up -d
|
||||
npm install
|
||||
npm run prisma:generate
|
||||
npm run db:migrate
|
||||
npm run start:dev
|
||||
```
|
||||
|
||||
API: http://localhost:3001/api/v1
|
||||
|
||||
Required for shop self-register / Meshkee write-through:
|
||||
|
||||
```bash
|
||||
MESHKEE_API_URL=https://api.meshkee.com/api/v1
|
||||
MESHKEE_API_TOKEN= # Meshkee superadmin access token
|
||||
```
|
||||
|
||||
Bootstrap platform tenant:
|
||||
|
||||
```bash
|
||||
MESHKEE_API_TOKEN=… \
|
||||
MESHKEE_SUPERADMIN_CELL=+98… \
|
||||
MESHKEE_SUPERADMIN_PASSWORD=… \
|
||||
npm run bootstrap:iranbay
|
||||
```
|
||||
|
||||
## Routes (v1)
|
||||
|
||||
| Method | Path | Notes |
|
||||
|--------|------|--------|
|
||||
| `GET` | `/businesses` | Marketplace listings |
|
||||
| `GET` | `/businesses/:slug` | Listing by slug |
|
||||
| `GET` | `/businesses/by-id/:id` | Listing by id |
|
||||
| `POST` | `/businesses/register-shop` | Auth user → new Meshkee shop + IranBay listing |
|
||||
| `POST` | `/businesses` | Link existing Meshkee business (admin) |
|
||||
| `PATCH` | `/businesses/:id` | Marketplace fields; name/slug/category → Meshkee |
|
||||
| `DELETE` | `/businesses/:id` | Remove listing |
|
||||
Executable
+27
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
||||
MIGRATIONS_DIR="$ROOT_DIR/database/migrations"
|
||||
CONTAINER="${POSTGRES_CONTAINER:-ariyaway-postgres}"
|
||||
DB_USER="${POSTGRES_USER:-ariyaway}"
|
||||
DB_NAME="${POSTGRES_DB:-ariyaway}"
|
||||
|
||||
"$ROOT_DIR/database/wait-for-postgres.sh"
|
||||
|
||||
run_migration() {
|
||||
local file="$1"
|
||||
echo "→ Running $(basename "$file")"
|
||||
docker exec -i "$CONTAINER" psql -U "$DB_USER" -d "$DB_NAME" < "$file"
|
||||
}
|
||||
|
||||
if [[ $# -gt 0 ]]; then
|
||||
run_migration "$1"
|
||||
else
|
||||
for file in "$MIGRATIONS_DIR"/*.sql; do
|
||||
[[ -f "$file" ]] || continue
|
||||
run_migration "$file"
|
||||
done
|
||||
fi
|
||||
|
||||
echo "Done."
|
||||
@@ -0,0 +1,127 @@
|
||||
-- AriyaWay marketplace — categories + businesses
|
||||
-- Content (products, blog, portfolio) stays in Meshkee.
|
||||
-- This DB only tracks marketplace presence + taxonomy.
|
||||
|
||||
CREATE OR REPLACE FUNCTION set_updated_at()
|
||||
RETURNS TRIGGER AS $$
|
||||
BEGIN
|
||||
NEW.updated_at = NOW();
|
||||
RETURN NEW;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- categories (marketplace taxonomy)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS categories (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
parent_id BIGINT,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
name_fa VARCHAR(255),
|
||||
icon_url TEXT,
|
||||
sort_order INT NOT NULL DEFAULT 0,
|
||||
is_active BOOLEAN NOT NULL DEFAULT TRUE,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT categories_slug_unique UNIQUE (slug),
|
||||
CONSTRAINT categories_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
|
||||
CONSTRAINT categories_parent_id_fkey
|
||||
FOREIGN KEY (parent_id) REFERENCES categories (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_parent_sort
|
||||
ON categories (parent_id, sort_order);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_is_active
|
||||
ON categories (is_active)
|
||||
WHERE is_active = TRUE;
|
||||
|
||||
DROP TRIGGER IF EXISTS categories_set_updated_at ON categories;
|
||||
CREATE TRIGGER categories_set_updated_at
|
||||
BEFORE UPDATE ON categories
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- businesses (marketplace listings linked to Meshkee)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS businesses (
|
||||
id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY,
|
||||
meshkee_business_id BIGINT NOT NULL,
|
||||
slug VARCHAR(100) NOT NULL,
|
||||
status VARCHAR(32) NOT NULL DEFAULT 'draft',
|
||||
primary_category_id BIGINT,
|
||||
is_featured BOOLEAN NOT NULL DEFAULT FALSE,
|
||||
sort_weight INT NOT NULL DEFAULT 0,
|
||||
website_url TEXT,
|
||||
-- Cached display fields (filled when linking / syncing from Meshkee)
|
||||
name VARCHAR(255) NOT NULL,
|
||||
tagline VARCHAR(500),
|
||||
description TEXT,
|
||||
logo_url TEXT,
|
||||
hero_url TEXT,
|
||||
province VARCHAR(100),
|
||||
country VARCHAR(100),
|
||||
telephone VARCHAR(50),
|
||||
since_year SMALLINT,
|
||||
published_at TIMESTAMPTZ,
|
||||
synced_at TIMESTAMPTZ,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT businesses_meshkee_business_id_unique UNIQUE (meshkee_business_id),
|
||||
CONSTRAINT businesses_slug_unique UNIQUE (slug),
|
||||
CONSTRAINT businesses_slug_format CHECK (slug ~ '^[a-z0-9]+(?:-[a-z0-9]+)*$'),
|
||||
CONSTRAINT businesses_status_check CHECK (
|
||||
status IN ('draft', 'pending_review', 'published', 'hidden', 'rejected')
|
||||
),
|
||||
CONSTRAINT businesses_primary_category_id_fkey
|
||||
FOREIGN KEY (primary_category_id) REFERENCES categories (id) ON DELETE SET NULL
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_status_featured
|
||||
ON businesses (status, is_featured, sort_weight DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_primary_category
|
||||
ON businesses (primary_category_id);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_businesses_meshkee_id
|
||||
ON businesses (meshkee_business_id);
|
||||
|
||||
DROP TRIGGER IF EXISTS businesses_set_updated_at ON businesses;
|
||||
CREATE TRIGGER businesses_set_updated_at
|
||||
BEFORE UPDATE ON businesses
|
||||
FOR EACH ROW
|
||||
EXECUTE FUNCTION set_updated_at();
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- business_categories (extra marketplace categories)
|
||||
-- ---------------------------------------------------------------------------
|
||||
CREATE TABLE IF NOT EXISTS business_categories (
|
||||
business_id BIGINT NOT NULL,
|
||||
category_id BIGINT NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
|
||||
CONSTRAINT business_categories_pkey PRIMARY KEY (business_id, category_id),
|
||||
CONSTRAINT business_categories_business_id_fkey
|
||||
FOREIGN KEY (business_id) REFERENCES businesses (id) ON DELETE CASCADE,
|
||||
CONSTRAINT business_categories_category_id_fkey
|
||||
FOREIGN KEY (category_id) REFERENCES categories (id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_business_categories_category
|
||||
ON business_categories (category_id);
|
||||
|
||||
-- ---------------------------------------------------------------------------
|
||||
-- Seed top-level marketplace categories (matches website mega menu roots)
|
||||
-- ---------------------------------------------------------------------------
|
||||
INSERT INTO categories (slug, name, name_fa, sort_order)
|
||||
VALUES
|
||||
('industry', 'Industry', 'صنعت', 1),
|
||||
('stores', 'Stores', 'فروشگاهها', 2),
|
||||
('wholesellers', 'Whole sellers', 'عمدهفروشان', 3),
|
||||
('services', 'Services', 'خدمات', 4),
|
||||
('food', 'Food', 'مواد غذایی', 5)
|
||||
ON CONFLICT (slug) DO NOTHING;
|
||||
@@ -0,0 +1,320 @@
|
||||
-- AriyaWay marketplace categories (Stores / Industries / Services) with search tags
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
|
||||
CREATE INDEX IF NOT EXISTS idx_categories_tags ON categories USING GIN (tags);
|
||||
|
||||
UPDATE categories SET is_active = FALSE, updated_at = NOW() WHERE is_active = TRUE;
|
||||
|
||||
-- Level 1
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
VALUES (NULL, 'Stores', 'فروشگاهها', 'stores', 1, ARRAY['retail', 'shop', 'store', 'stores', 'فروشگاه', 'فروشگاهها', 'مغازه']::text[], TRUE)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
VALUES (NULL, 'Industries', 'صنایع', 'industries', 2, ARRAY['industrial', 'industries', 'industry', 'manufacturing', 'صنایع', 'صنعت', 'کارخانه']::text[], TRUE)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
VALUES (NULL, 'Services', 'خدمات', 'services', 3, ARRAY['service', 'services', 'خدمات']::text[], TRUE)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Level 2
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
SELECT p.id, v.name, v.name_fa, v.slug, v.sort_order, v.tags, TRUE
|
||||
FROM (VALUES
|
||||
('stores', 'Fashion & Apparel', 'مد و پوشاک', 'fashion-apparel', 'مد و پوشاک', 1, ARRAY['apparel', 'clothing', 'fashion', 'fashion & apparel', 'fashion apparel', 'مد', 'مد و پوشاک', 'پوشاک']::text[]),
|
||||
('stores', 'Food & Grocery', 'مواد غذایی و سوپرمارکت', 'food-grocery', 'مواد غذایی و سوپرمارکت', 2, ARRAY['food', 'food & grocery', 'food grocery', 'grocery', 'خواربار', 'مواد غذایی', 'مواد غذایی و سوپرمارکت']::text[]),
|
||||
('stores', 'Home & Living', 'خانه و زندگی', 'home-living-retail', 'خانه و زندگی', 3, ARRAY['home', 'home & living', 'home living retail', 'living', 'خانه', 'خانه و زندگی', 'لوازم خانگی']::text[]),
|
||||
('stores', 'Electronics & Tech', 'الکترونیک و فناوری', 'electronics-tech-retail', 'الکترونیک و فناوری', 4, ARRAY['electronics', 'electronics & tech', 'electronics tech retail', 'tech', 'الکترونیک', 'الکترونیک و فناوری', 'دیجیتال']::text[]),
|
||||
('stores', 'Beauty & Health Retail', 'آرایشی و بهداشتی', 'beauty-health-retail', 'آرایشی و بهداشتی', 5, ARRAY['beauty', 'beauty & health retail', 'beauty health retail', 'health retail', 'آرایشی', 'آرایشی و بهداشتی', 'بهداشتی']::text[]),
|
||||
('industries', 'Manufacturing', 'تولید و ساخت', 'manufacturing', 'تولید و ساخت', 1, ARRAY['factory', 'manufacturing', 'تولید', 'تولید و ساخت', 'کارخانه']::text[]),
|
||||
('industries', 'Construction & Building', 'ساختمان و عمران', 'construction-building', 'ساختمان و عمران', 2, ARRAY['building', 'construction', 'construction & building', 'construction building', 'ساختمان', 'ساختمان و عمران', 'عمران']::text[]),
|
||||
('industries', 'Agriculture & Raw Materials', 'کشاورزی و مواد اولیه', 'agriculture-raw-materials', 'کشاورزی و مواد اولیه', 3, ARRAY['agriculture', 'agriculture & raw materials', 'agriculture raw materials', 'farming', 'کشاورزی', 'کشاورزی و مواد اولیه']::text[]),
|
||||
('industries', 'Energy & Utilities', 'انرژی و تأسیسات', 'energy-utilities', 'انرژی و تأسیسات', 4, ARRAY['energy', 'energy & utilities', 'energy utilities', 'utilities', 'انرژی', 'انرژی و تأسیسات']::text[]),
|
||||
('services', 'Professional Services', 'خدمات حرفهای', 'professional-services', 'خدمات حرفهای', 1, ARRAY['professional', 'professional services', 'حرفه ای', 'خدمات حرفهای']::text[]),
|
||||
('services', 'Technology Services', 'خدمات فناوری', 'technology-services', 'خدمات فناوری', 2, ARRAY['it service', 'tech service', 'technology services', 'خدمات فناوری', 'فناوری']::text[]),
|
||||
('services', 'Personal & Local Services', 'خدمات محلی و شخصی', 'personal-local-services', 'خدمات محلی و شخصی', 3, ARRAY['local service', 'personal & local services', 'personal local services', 'personal service', 'خدمات محلی', 'خدمات محلی و شخصی']::text[]),
|
||||
('services', 'Medical', 'پزشکی', 'medical', 'پزشکی', 4, ARRAY['doctor', 'healthcare', 'medical', 'درمان', 'پزشکی']::text[]),
|
||||
('services', 'Automotive Services', 'خدمات خودرو', 'automotive-services', 'خدمات خودرو', 5, ARRAY['auto service', 'automotive services', 'car service', 'خدمات خودرو', 'خودرو']::text[])
|
||||
) AS v(parent_slug, name, name_fa, slug, description, sort_order, tags)
|
||||
JOIN categories p ON p.slug = v.parent_slug
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Level 3
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
SELECT p.id, v.name, v.name_fa, v.slug, v.sort_order, v.tags, TRUE
|
||||
FROM (VALUES
|
||||
('fashion-apparel', 'Clothing', 'لباس', 'clothing', 'لباس', 1, ARRAY['apparel', 'clothes', 'clothing', 'لباس']::text[]),
|
||||
('fashion-apparel', 'Shoes & Bags', 'کفش و کیف', 'shoes-bags', 'کفش و کیف', 2, ARRAY['bags', 'footwear', 'shoes', 'shoes & bags', 'shoes bags', 'کفش', 'کفش و کیف', 'کیف']::text[]),
|
||||
('fashion-apparel', 'Jewelry & Watches', 'جواهرات و ساعت', 'jewelry-watches', 'جواهرات و ساعت', 3, ARRAY['jewellery', 'jewelry', 'jewelry & watches', 'jewelry watches', 'watches', 'جواهر', 'جواهرات و ساعت', 'ساعت']::text[]),
|
||||
('food-grocery', 'Supermarket & Grocery', 'سوپرمارکت و خواربار', 'supermarket-grocery', 'سوپرمارکت و خواربار', 1, ARRAY['grocery', 'supermarket', 'supermarket & grocery', 'supermarket grocery', 'سوپرمارکت', 'سوپرمارکت و خواربار', 'هایپر']::text[]),
|
||||
('food-grocery', 'Specialty Food', 'مواد غذایی تخصصی', 'specialty-food', 'مواد غذایی تخصصی', 2, ARRAY['specialty food', 'مواد غذایی تخصصی', 'مواد غذایی خاص']::text[]),
|
||||
('food-grocery', 'Beverages', 'نوشیدنی', 'beverages-retail', 'نوشیدنی', 3, ARRAY['beverage', 'beverages', 'beverages retail', 'drink', 'نوشیدنی']::text[]),
|
||||
('home-living-retail', 'Furniture', 'مبلمان', 'furniture', 'مبلمان', 1, ARRAY['furniture', 'مبل', 'مبلمان']::text[]),
|
||||
('home-living-retail', 'Home Decor', 'دکوراسیون', 'home-decor', 'دکوراسیون', 2, ARRAY['decor', 'decoration', 'home decor', 'دکور', 'دکوراسیون']::text[]),
|
||||
('home-living-retail', 'Kitchen & Appliances', 'آشپزخانه و لوازم خانگی', 'kitchen-appliances', 'آشپزخانه و لوازم خانگی', 3, ARRAY['appliance', 'kitchen', 'kitchen & appliances', 'kitchen appliances', 'آشپزخانه', 'آشپزخانه و لوازم خانگی', 'لوازم خانگی']::text[]),
|
||||
('electronics-tech-retail', 'Mobile & Accessories', 'موبایل و لوازم جانبی', 'mobile-accessories', 'موبایل و لوازم جانبی', 1, ARRAY['mobile', 'mobile & accessories', 'mobile accessories', 'phone', 'تلفن', 'موبایل', 'موبایل و لوازم جانبی']::text[]),
|
||||
('electronics-tech-retail', 'Computers', 'کامپیوتر', 'computers', 'کامپیوتر', 2, ARRAY['computer', 'computers', 'pc', 'رایانه', 'کامپیوتر']::text[]),
|
||||
('electronics-tech-retail', 'Audio & Visual', 'صوتی و تصویری', 'audio-visual', 'صوتی و تصویری', 3, ARRAY['audio', 'audio & visual', 'audio visual', 'av', 'visual', 'صوتی تصویری', 'صوتی و تصویری']::text[]),
|
||||
('beauty-health-retail', 'Cosmetics & Personal Care', 'آرایشی و مراقبت شخصی', 'cosmetics-personal-care', 'آرایشی و مراقبت شخصی', 1, ARRAY['cosmetics', 'cosmetics & personal care', 'cosmetics personal care', 'personal care', 'آرایشی', 'آرایشی و مراقبت شخصی']::text[]),
|
||||
('beauty-health-retail', 'Pharmacy & Wellness', 'داروخانه و سلامت', 'pharmacy-wellness-retail', 'داروخانه و سلامت', 2, ARRAY['pharmacy', 'pharmacy & wellness', 'pharmacy wellness retail', 'wellness', 'داروخانه', 'داروخانه و سلامت', 'سلامت']::text[]),
|
||||
('manufacturing', 'Textile & Apparel', 'نساجی و پوشاک', 'textile-apparel-mfg', 'نساجی و پوشاک', 1, ARRAY['apparel mfg', 'textile', 'textile & apparel', 'textile apparel mfg', 'نساجی', 'نساجی و پوشاک', 'پوشاک']::text[]),
|
||||
('manufacturing', 'Food Processing', 'صنایع غذایی', 'food-processing-mfg', 'صنایع غذایی', 2, ARRAY['food factory', 'food processing', 'food processing mfg', 'صنایع غذایی']::text[]),
|
||||
('manufacturing', 'Metal & Machinery', 'فلز و ماشینآلات', 'metal-machinery-mfg', 'فلز و ماشینآلات', 3, ARRAY['machinery', 'metal', 'metal & machinery', 'metal machinery mfg', 'فلز', 'فلز و ماشینآلات', 'ماشین آلات']::text[]),
|
||||
('manufacturing', 'Electrical & Cable', 'برق و کابل', 'electrical-cable-mfg', 'برق و کابل', 4, ARRAY['cable', 'electrical', 'electrical & cable', 'electrical cable mfg', 'wire', 'برق و کابل', 'سیم', 'کابل']::text[]),
|
||||
('manufacturing', 'Automotive Parts', 'قطعات خودرو', 'automotive-parts-mfg', 'قطعات خودرو', 5, ARRAY['auto parts', 'automotive', 'automotive parts', 'automotive parts mfg', 'قطعات خودرو']::text[]),
|
||||
('manufacturing', 'Chemicals & Materials', 'شیمیایی و مواد', 'chemicals-materials-mfg', 'شیمیایی و مواد', 6, ARRAY['chemical', 'chemicals & materials', 'chemicals materials mfg', 'materials', 'شیمیایی', 'شیمیایی و مواد']::text[]),
|
||||
('manufacturing', 'Ceramics & Glass', 'سرامیک و شیشه', 'ceramics-glass-mfg', 'سرامیک و شیشه', 7, ARRAY['ceramic', 'ceramics & glass', 'ceramics glass mfg', 'glass', 'سرامیک', 'سرامیک و شیشه', 'شیشه']::text[]),
|
||||
('manufacturing', 'Pharmaceutical & Health', 'دارویی و سلامت', 'pharma-health-mfg', 'دارویی و سلامت', 8, ARRAY['pharma', 'pharma health mfg', 'pharmaceutical', 'pharmaceutical & health', 'دارویی', 'دارویی و سلامت']::text[]),
|
||||
('manufacturing', 'Other Factories', 'سایر کارخانهها', 'other-factories', 'سایر کارخانهها', 9, ARRAY['other factories', 'other factory', 'سایر کارخانه', 'سایر کارخانهها']::text[]),
|
||||
('construction-building', 'Contractors', 'پیمانکاران', 'contractors', 'پیمانکاران', 1, ARRAY['contractor', 'contractors', 'پیمانکار', 'پیمانکاران']::text[]),
|
||||
('construction-building', 'Building Supplies', 'مصالح ساختمانی', 'building-supplies', 'مصالح ساختمانی', 2, ARRAY['building supplies', 'building supply', 'مصالح', 'مصالح ساختمانی']::text[]),
|
||||
('agriculture-raw-materials', 'Farming', 'کشاورزی', 'farming', 'کشاورزی', 1, ARRAY['farm', 'farming', 'کشاورز', 'کشاورزی']::text[]),
|
||||
('agriculture-raw-materials', 'Mining & Extraction', 'معدن و استخراج', 'mining-extraction', 'معدن و استخراج', 2, ARRAY['mining', 'mining & extraction', 'mining extraction', 'معدن', 'معدن و استخراج']::text[]),
|
||||
('energy-utilities', 'Energy', 'انرژی', 'energy', 'انرژی', 1, ARRAY['energy', 'power', 'انرژی', 'برق']::text[]),
|
||||
('energy-utilities', 'Industrial Services', 'خدمات صنعتی', 'industrial-services', 'خدمات صنعتی', 2, ARRAY['industrial service', 'industrial services', 'خدمات صنعتی']::text[]),
|
||||
('professional-services', 'Business Consulting', 'مشاوره کسبوکار', 'business-consulting', 'مشاوره کسبوکار', 1, ARRAY['business consulting', 'consulting', 'مشاوره', 'مشاوره کسبوکار']::text[]),
|
||||
('professional-services', 'Design & Creative', 'طراحی و خلاقیت', 'design-creative', 'طراحی و خلاقیت', 2, ARRAY['creative', 'design', 'design & creative', 'design creative', 'طراحی', 'طراحی و خلاقیت']::text[]),
|
||||
('professional-services', 'Education & Training', 'آموزش', 'education-training', 'آموزش', 3, ARRAY['education', 'education & training', 'education training', 'training', 'آموزش']::text[]),
|
||||
('technology-services', 'Software & IT', 'نرمافزار و فناوری اطلاعات', 'software-it', 'نرمافزار و فناوری اطلاعات', 1, ARRAY['it', 'software', 'software & it', 'software it', 'نرم افزار', 'نرمافزار و فناوری اطلاعات']::text[]),
|
||||
('technology-services', 'Digital Marketing', 'بازاریابی دیجیتال', 'digital-marketing', 'بازاریابی دیجیتال', 2, ARRAY['digital marketing', 'بازاریابی دیجیتال', 'دیجیتال مارکتینگ']::text[]),
|
||||
('personal-local-services', 'Beauty & Care', 'زیبایی و مراقبت', 'beauty-care-services', 'زیبایی و مراقبت', 1, ARRAY['beauty & care', 'beauty care services', 'beauty salon', 'آرایشگاه', 'زیبایی و مراقبت']::text[]),
|
||||
('personal-local-services', 'Home Services', 'خدمات منزل', 'home-services', 'خدمات منزل', 2, ARRAY['home service', 'home services', 'خدمات منزل']::text[]),
|
||||
('personal-local-services', 'Hospitality', 'پذیرایی و اقامت', 'hospitality', 'پذیرایی و اقامت', 3, ARRAY['hospitality', 'رستوران', 'هتل', 'پذیرایی و اقامت']::text[]),
|
||||
('medical', 'Doctors & Specialists', 'پزشکان و متخصصان', 'doctors-specialists', 'پزشکان و متخصصان', 1, ARRAY['doctor', 'doctors & specialists', 'doctors specialists', 'specialist', 'متخصص', 'مطب', 'پزشک', 'پزشکان و متخصصان']::text[]),
|
||||
('medical', 'Clinics & Centers', 'کلینیکها و مراکز', 'clinics-centers', 'کلینیکها و مراکز', 2, ARRAY['center', 'clinic', 'clinics & centers', 'clinics centers', 'مرکز', 'کلینیک', 'کلینیکها و مراکز']::text[]),
|
||||
('medical', 'Hospitals & Labs', 'بیمارستان و آزمایشگاه', 'hospitals-labs', 'بیمارستان و آزمایشگاه', 3, ARRAY['hospital', 'hospitals & labs', 'hospitals labs', 'lab', 'آزمایشگاه', 'بیمارستان', 'بیمارستان و آزمایشگاه']::text[]),
|
||||
('medical', 'Care & Support', 'مراقبت و پشتیبانی', 'care-support', 'مراقبت و پشتیبانی', 4, ARRAY['care', 'care & support', 'care support', 'support', 'مراقبت', 'مراقبت و پشتیبانی']::text[]),
|
||||
('automotive-services', 'Vehicle Sales', 'فروش خودرو', 'vehicle-sales', 'فروش خودرو', 1, ARRAY['car sales', 'vehicle sales', 'فروش خودرو']::text[]),
|
||||
('automotive-services', 'Repair & Parts', 'تعمیر و قطعات', 'repair-parts', 'تعمیر و قطعات', 2, ARRAY['auto repair', 'repair & parts', 'repair parts', 'تعمیر خودرو', 'تعمیر و قطعات']::text[])
|
||||
) AS v(parent_slug, name, name_fa, slug, description, sort_order, tags)
|
||||
JOIN categories p ON p.slug = v.parent_slug
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Level 4
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
SELECT p.id, v.name, v.name_fa, v.slug, v.sort_order, v.tags, TRUE
|
||||
FROM (VALUES
|
||||
('clothing', 'Women''s Clothing', 'لباس زنانه', 'womens-clothing', 'لباس زنانه', 1, ARRAY['ladies', 'women', 'women''s clothing', 'womens clothing', 'بانوان', 'زنانه', 'لباس زنانه']::text[]),
|
||||
('clothing', 'Men''s Clothing', 'لباس مردانه', 'mens-clothing', 'لباس مردانه', 2, ARRAY['gents', 'men', 'men''s clothing', 'mens clothing', 'لباس مردانه', 'مردانه']::text[]),
|
||||
('clothing', 'Kids & Baby', 'کودک و نوزاد', 'kids-baby-clothing', 'کودک و نوزاد', 3, ARRAY['baby', 'children', 'kids', 'kids & baby', 'kids baby clothing', 'بچگانه', 'نوزاد', 'کودک', 'کودک و نوزاد']::text[]),
|
||||
('clothing', 'Sportswear', 'لباس ورزشی', 'sportswear', 'لباس ورزشی', 4, ARRAY['activewear', 'sport', 'sportswear', 'لباس ورزشی', 'ورزشی']::text[]),
|
||||
('shoes-bags', 'Shoes', 'کفش', 'shoes', 'کفش', 1, ARRAY['footwear', 'shoes', 'sneakers', 'کفش']::text[]),
|
||||
('shoes-bags', 'Bags & Luggage', 'کیف و چمدان', 'bags-luggage', 'کیف و چمدان', 2, ARRAY['bag', 'bags & luggage', 'bags luggage', 'luggage', 'suitcase', 'چمدان', 'کیف', 'کیف و چمدان']::text[]),
|
||||
('shoes-bags', 'Accessories', 'اکسسوری', 'fashion-accessories', 'اکسسوری', 3, ARRAY['accessories', 'accessory', 'belt', 'fashion accessories', 'scarf', 'اکسسوری']::text[]),
|
||||
('jewelry-watches', 'Jewelry', 'جواهرات', 'jewelry', 'جواهرات', 1, ARRAY['jewellery', 'jewelry', 'necklace', 'ring', 'جواهر', 'جواهرات', 'طلاآلات']::text[]),
|
||||
('jewelry-watches', 'Watches', 'ساعت', 'watches', 'ساعت', 2, ARRAY['watch', 'watches', 'ساعت', 'ساعت مچی']::text[]),
|
||||
('jewelry-watches', 'Gold & Silver', 'طلا و نقره', 'gold-silver', 'طلا و نقره', 3, ARRAY['gold', 'gold & silver', 'gold silver', 'silver', 'طلا', 'طلا فروشی', 'طلا و نقره', 'نقره']::text[]),
|
||||
('supermarket-grocery', 'Supermarket', 'سوپرمارکت', 'supermarket', 'سوپرمارکت', 1, ARRAY['grocery store', 'hypermarket', 'supermarket', 'سوپر', 'سوپرمارکت', 'هایپرمارکت']::text[]),
|
||||
('supermarket-grocery', 'Convenience Store', 'بقالی', 'convenience-store', 'بقالی', 2, ARRAY['convenience store', 'corner store', 'minimarket', 'بقالی', 'مینی مارکت']::text[]),
|
||||
('supermarket-grocery', 'Organic & Healthy', 'ارگانیک و سالم', 'organic-healthy', 'ارگانیک و سالم', 3, ARRAY['healthy', 'organic', 'organic & healthy', 'organic healthy', 'ارگانیک', 'ارگانیک و سالم', 'سالم']::text[]),
|
||||
('specialty-food', 'Bakery & Pastry', 'نانوایی و شیرینی', 'bakery-pastry', 'نانوایی و شیرینی', 1, ARRAY['bakery', 'bakery & pastry', 'bakery pastry', 'pastry', 'شیرینی', 'قنادی', 'نانوایی', 'نانوایی و شیرینی']::text[]),
|
||||
('specialty-food', 'Nuts & Dried Fruit', 'آجیل و خشکبار', 'nuts-dried-fruit', 'آجیل و خشکبار', 2, ARRAY['dried fruit', 'nuts', 'nuts & dried fruit', 'nuts dried fruit', 'آجیل', 'آجیل و خشکبار', 'خشکبار']::text[]),
|
||||
('specialty-food', 'Sweets & Confectionery', 'شیرینی و آبنبات', 'sweets-confectionery', 'شیرینی و آبنبات', 3, ARRAY['candy', 'confectionery', 'sweets', 'sweets & confectionery', 'sweets confectionery', 'آبنبات', 'شیرینی', 'شیرینی و آبنبات']::text[]),
|
||||
('specialty-food', 'Spices & Herbs', 'ادویه و گیاهان دارویی', 'spices-herbs', 'ادویه و گیاهان دارویی', 4, ARRAY['herb', 'saffron', 'spice', 'spices & herbs', 'spices herbs', 'ادویه', 'ادویه و گیاهان دارویی', 'زعفران']::text[]),
|
||||
('beverages-retail', 'Coffee & Tea', 'قهوه و چای', 'coffee-tea', 'قهوه و چای', 1, ARRAY['coffee', 'coffee & tea', 'coffee tea', 'tea', 'قهوه', 'قهوه و چای', 'چای']::text[]),
|
||||
('beverages-retail', 'Soft Drinks', 'نوشابه', 'soft-drinks', 'نوشابه', 2, ARRAY['soda', 'soft drink', 'soft drinks', 'نوشابه']::text[]),
|
||||
('beverages-retail', 'Dairy & Juice', 'لبنیات و آبمیوه', 'dairy-juice', 'لبنیات و آبمیوه', 3, ARRAY['dairy', 'dairy & juice', 'dairy juice', 'juice', 'milk', 'آبمیوه', 'لبنیات', 'لبنیات و آبمیوه']::text[]),
|
||||
('furniture', 'Home Furniture', 'مبلمان خانگی', 'home-furniture', 'مبلمان خانگی', 1, ARRAY['bedroom', 'home furniture', 'sofa', 'مبلمان خانگی', 'مبلمان منزل']::text[]),
|
||||
('furniture', 'Office Furniture', 'مبلمان اداری', 'office-furniture', 'مبلمان اداری', 2, ARRAY['desk', 'office chair', 'office furniture', 'اداری', 'مبلمان اداری']::text[]),
|
||||
('furniture', 'Outdoor Furniture', 'مبلمان فضای باز', 'outdoor-furniture', 'مبلمان فضای باز', 3, ARRAY['garden furniture', 'outdoor furniture', 'فضای باز', 'مبلمان فضای باز']::text[]),
|
||||
('home-decor', 'Rugs & Carpets', 'فرش و قالی', 'rugs-carpets', 'فرش و قالی', 1, ARRAY['carpet', 'rug', 'rugs & carpets', 'rugs carpets', 'فرش', 'فرش و قالی', 'قالی', 'گلیم']::text[]),
|
||||
('home-decor', 'Lighting', 'روشنایی', 'lighting', 'روشنایی', 2, ARRAY['lamp', 'light', 'lighting', 'روشنایی', 'لوستر', 'چراغ']::text[]),
|
||||
('home-decor', 'Decor Accessories', 'لوازم دکوری', 'decor-accessories', 'لوازم دکوری', 3, ARRAY['decor', 'decor accessories', 'ornament', 'دکوری', 'لوازم دکوری']::text[]),
|
||||
('kitchen-appliances', 'Kitchenware', 'ظروف آشپزخانه', 'kitchenware', 'ظروف آشپزخانه', 1, ARRAY['cookware', 'kitchen tools', 'kitchenware', 'ظروف', 'ظروف آشپزخانه']::text[]),
|
||||
('kitchen-appliances', 'Small Appliances', 'لوازم کوچک خانگی', 'small-appliances', 'لوازم کوچک خانگی', 2, ARRAY['blender', 'small appliances', 'toaster', 'لوازم کوچک', 'لوازم کوچک خانگی']::text[]),
|
||||
('kitchen-appliances', 'Large Appliances', 'لوازم بزرگ خانگی', 'large-appliances', 'لوازم بزرگ خانگی', 3, ARRAY['fridge', 'large appliances', 'washer', 'لوازم بزرگ خانگی', 'ماشین لباسشویی', 'یخچال']::text[]),
|
||||
('mobile-accessories', 'Mobile Phones', 'گوشی موبایل', 'mobile-phones', 'گوشی موبایل', 1, ARRAY['mobile phones', 'phone shop', 'smartphone', 'فروشگاه موبایل', 'گوشی', 'گوشی موبایل']::text[]),
|
||||
('mobile-accessories', 'Accessories', 'لوازم جانبی موبایل', 'mobile-accessories-store', 'لوازم جانبی موبایل', 2, ARRAY['accessories', 'case', 'charger', 'mobile accessories store', 'لوازم جانبی', 'لوازم جانبی موبایل']::text[]),
|
||||
('mobile-accessories', 'Wearables', 'گجت پوشیدنی', 'wearables', 'گجت پوشیدنی', 3, ARRAY['smartwatch', 'wearables', 'ساعت هوشمند', 'گجت پوشیدنی']::text[]),
|
||||
('computers', 'Laptops', 'لپتاپ', 'laptops', 'لپتاپ', 1, ARRAY['laptops', 'notebook', 'لپ تاپ', 'لپتاپ', 'لپتاپ']::text[]),
|
||||
('computers', 'Desktops & Parts', 'دسکتاپ و قطعات', 'desktops-parts', 'دسکتاپ و قطعات', 2, ARRAY['cpu', 'desktops & parts', 'desktops parts', 'pc parts', 'دسکتاپ و قطعات', 'قطعات کامپیوتر']::text[]),
|
||||
('computers', 'Peripherals', 'لوازم جانبی کامپیوتر', 'peripherals', 'لوازم جانبی کامپیوتر', 3, ARRAY['keyboard', 'monitor', 'mouse', 'peripherals', 'لوازم جانبی کامپیوتر', 'مانیتور']::text[]),
|
||||
('audio-visual', 'TVs', 'تلویزیون', 'tvs', 'تلویزیون', 1, ARRAY['television', 'tv store', 'tvs', 'تلویزیون']::text[]),
|
||||
('audio-visual', 'Audio Systems', 'سیستم صوتی', 'audio-systems', 'سیستم صوتی', 2, ARRAY['audio systems', 'sound', 'speaker', 'اسپیکر', 'سیستم صوتی']::text[]),
|
||||
('audio-visual', 'Cameras', 'دوربین', 'cameras', 'دوربین', 3, ARRAY['camera', 'cameras', 'photography', 'دوربین', 'دوربین عکاسی']::text[]),
|
||||
('cosmetics-personal-care', 'Skincare', 'مراقبت پوست', 'skincare', 'مراقبت پوست', 1, ARRAY['cream', 'skin care', 'skincare', 'مراقبت پوست', 'پوست']::text[]),
|
||||
('cosmetics-personal-care', 'Makeup', 'آرایش', 'makeup', 'آرایش', 2, ARRAY['cosmetic', 'make up', 'makeup', 'آرایش']::text[]),
|
||||
('cosmetics-personal-care', 'Hair Care', 'مراقبت مو', 'hair-care', 'مراقبت مو', 3, ARRAY['hair', 'hair care', 'shampoo', 'مراقبت مو', 'مو']::text[]),
|
||||
('pharmacy-wellness-retail', 'Pharmacy', 'داروخانه', 'pharmacy', 'داروخانه', 1, ARRAY['chemist', 'drug store', 'drugstore', 'pharmacy', 'pharmacy store', 'دارو', 'داروخانه', 'شبانه روزی']::text[]),
|
||||
('pharmacy-wellness-retail', 'Supplements', 'مکملها', 'supplements', 'مکملها', 2, ARRAY['supplement', 'supplements', 'vitamin', 'مکمل', 'مکملها', 'ویتامین']::text[]),
|
||||
('pharmacy-wellness-retail', 'Medical Supplies', 'لوازم پزشکی', 'medical-supplies-retail', 'لوازم پزشکی', 3, ARRAY['medical supplies', 'medical supplies retail', 'medical supply', 'لوازم پزشکی']::text[]),
|
||||
('textile-apparel-mfg', 'Garment Factory', 'کارخانه پوشاک', 'garment-factory', 'کارخانه پوشاک', 1, ARRAY['clothing factory', 'garment factory', 'دوخت', 'کارخانه لباس', 'کارخانه پوشاک']::text[]),
|
||||
('textile-apparel-mfg', 'Fabric & Textile Mill', 'کارخانه پارچه و نساجی', 'fabric-textile-mill', 'کارخانه پارچه و نساجی', 2, ARRAY['fabric & textile mill', 'fabric textile mill', 'textile mill', 'نساجی', 'پارچه', 'کارخانه پارچه و نساجی']::text[]),
|
||||
('textile-apparel-mfg', 'Knitwear Factory', 'کارخانه تریکو', 'knitwear-factory', 'کارخانه تریکو', 3, ARRAY['knitwear', 'knitwear factory', 'تریکو', 'کارخانه تریکو']::text[]),
|
||||
('textile-apparel-mfg', 'Leather Goods Factory', 'کارخانه چرم', 'leather-goods-factory', 'کارخانه چرم', 4, ARRAY['leather factory', 'leather goods factory', 'چرم', 'کارخانه چرم']::text[]),
|
||||
('food-processing-mfg', 'Dairy Factory', 'کارخانه لبنیات', 'dairy-factory', 'کارخانه لبنیات', 1, ARRAY['dairy factory', 'dairy plant', 'لبنیات', 'کارخانه لبنیات']::text[]),
|
||||
('food-processing-mfg', 'Meat Processing Plant', 'کارخانه فرآوری گوشت', 'meat-processing-plant', 'کارخانه فرآوری گوشت', 2, ARRAY['meat factory', 'meat processing plant', 'کارخانه فرآوری گوشت', 'گوشت']::text[]),
|
||||
('food-processing-mfg', 'Nuts & Dry Goods Factory', 'کارخانه آجیل و خشکبار', 'nuts-dry-goods-factory', 'کارخانه آجیل و خشکبار', 3, ARRAY['nuts & dry goods factory', 'nuts dry goods factory', 'nuts factory', 'آجیل', 'کارخانه آجیل و خشکبار']::text[]),
|
||||
('food-processing-mfg', 'Beverage Factory', 'کارخانه نوشیدنی', 'beverage-factory', 'کارخانه نوشیدنی', 4, ARRAY['beverage factory', 'drink factory', 'نوشیدنی', 'کارخانه نوشیدنی']::text[]),
|
||||
('food-processing-mfg', 'Flour Mill', 'آسیاب آرد', 'flour-mill', 'آسیاب آرد', 5, ARRAY['flour', 'flour mill', 'mill', 'آرد', 'آسیاب آرد']::text[]),
|
||||
('food-processing-mfg', 'Canning Factory', 'کارخانه کنسرو', 'canning-factory', 'کارخانه کنسرو', 6, ARRAY['cannery', 'canning factory', 'کارخانه کنسرو', 'کنسرو']::text[]),
|
||||
('metal-machinery-mfg', 'Metal Fabrication Plant', 'کارخانه ساخت فلزی', 'metal-fabrication-plant', 'کارخانه ساخت فلزی', 1, ARRAY['fabrication', 'metal fabrication plant', 'ورق کاری', 'کارخانه ساخت فلزی']::text[]),
|
||||
('metal-machinery-mfg', 'Machinery Factory', 'کارخانه ماشینآلات', 'machinery-factory', 'کارخانه ماشینآلات', 2, ARRAY['machine factory', 'machinery factory', 'ماشین سازی', 'کارخانه ماشینآلات']::text[]),
|
||||
('metal-machinery-mfg', 'Tools Factory', 'کارخانه ابزار', 'tools-factory', 'کارخانه ابزار', 3, ARRAY['tool factory', 'tools factory', 'ابزار', 'کارخانه ابزار']::text[]),
|
||||
('metal-machinery-mfg', 'Foundry', 'ریختهگری', 'foundry', 'ریختهگری', 4, ARRAY['casting', 'foundry', 'ریخته گری', 'ریختهگری']::text[]),
|
||||
('electrical-cable-mfg', 'Cable Factory', 'کارخانه کابل', 'cable-factory', 'کارخانه کابل', 1, ARRAY['cable factory', 'cable manufacturer', 'سیم و کابل', 'کابل سازی', 'کارخانه کابل']::text[]),
|
||||
('electrical-cable-mfg', 'Wire Factory', 'کارخانه سیم', 'wire-factory', 'کارخانه سیم', 2, ARRAY['wire factory', 'wire manufacturer', 'سیم سازی', 'کارخانه سیم']::text[]),
|
||||
('electrical-cable-mfg', 'Electrical Equipment Factory', 'کارخانه تجهیزات برقی', 'electrical-equipment-factory', 'کارخانه تجهیزات برقی', 3, ARRAY['electrical equipment factory', 'electrical factory', 'تجهیزات برقی', 'کارخانه تجهیزات برقی']::text[]),
|
||||
('electrical-cable-mfg', 'Transformer Factory', 'کارخانه ترانسفورماتور', 'transformer-factory', 'کارخانه ترانسفورماتور', 4, ARRAY['transformer', 'transformer factory', 'ترانسفورماتور', 'کارخانه ترانسفورماتور']::text[]),
|
||||
('automotive-parts-mfg', 'Auto Parts Factory', 'کارخانه قطعات خودرو', 'auto-parts-factory', 'کارخانه قطعات خودرو', 1, ARRAY['auto parts factory', 'car parts factory', 'قطعه سازی خودرو', 'کارخانه قطعات خودرو']::text[]),
|
||||
('automotive-parts-mfg', 'Tire Factory', 'کارخانه لاستیک', 'tire-factory', 'کارخانه لاستیک', 2, ARRAY['tire factory', 'tyre', 'لاستیک', 'کارخانه لاستیک']::text[]),
|
||||
('automotive-parts-mfg', 'Battery Factory', 'کارخانه باتری', 'battery-factory', 'کارخانه باتری', 3, ARRAY['battery factory', 'car battery', 'باتری', 'کارخانه باتری']::text[]),
|
||||
('automotive-parts-mfg', 'Automotive Electronics Factory', 'کارخانه الکترونیک خودرو', 'automotive-electronics-factory', 'کارخانه الکترونیک خودرو', 4, ARRAY['auto electronics', 'automotive electronics factory', 'الکترونیک خودرو', 'کارخانه الکترونیک خودرو']::text[]),
|
||||
('chemicals-materials-mfg', 'Chemical Plant', 'کارخانه شیمیایی', 'chemical-plant', 'کارخانه شیمیایی', 1, ARRAY['chemical plant', 'chemicals factory', 'شیمیایی', 'کارخانه شیمیایی']::text[]),
|
||||
('chemicals-materials-mfg', 'Plastics Factory', 'کارخانه پلاستیک', 'plastics-factory', 'کارخانه پلاستیک', 2, ARRAY['plastic factory', 'plastics factory', 'پلاستیک', 'کارخانه پلاستیک']::text[]),
|
||||
('chemicals-materials-mfg', 'Paint Factory', 'کارخانه رنگ', 'paint-factory', 'کارخانه رنگ', 3, ARRAY['paint factory', 'paint manufacturer', 'رنگ سازی', 'کارخانه رنگ']::text[]),
|
||||
('chemicals-materials-mfg', 'Rubber Factory', 'کارخانه لاستیک صنعتی', 'rubber-factory', 'کارخانه لاستیک صنعتی', 4, ARRAY['rubber', 'rubber factory', 'لاستیک صنعتی', 'کارخانه لاستیک صنعتی']::text[]),
|
||||
('chemicals-materials-mfg', 'Building Materials Factory', 'کارخانه مصالح ساختمانی', 'building-materials-factory', 'کارخانه مصالح ساختمانی', 5, ARRAY['building materials', 'building materials factory', 'مصالح', 'کارخانه مصالح ساختمانی']::text[]),
|
||||
('ceramics-glass-mfg', 'Ceramic Factory', 'کارخانه سرامیک', 'ceramic-factory', 'کارخانه سرامیک', 1, ARRAY['ceramic factory', 'ceramics plant', 'سرامیک سازی', 'کارخانه سرامیک']::text[]),
|
||||
('ceramics-glass-mfg', 'Tile Factory', 'کارخانه کاشی', 'tile-factory', 'کارخانه کاشی', 2, ARRAY['tile factory', 'tile manufacturer', 'سرامیک کف', 'کارخانه کاشی', 'کاشی']::text[]),
|
||||
('ceramics-glass-mfg', 'Porcelain Factory', 'کارخانه چینی', 'porcelain-factory', 'کارخانه چینی', 3, ARRAY['porcelain', 'porcelain factory', 'چینی', 'کارخانه چینی']::text[]),
|
||||
('ceramics-glass-mfg', 'Glass Factory', 'کارخانه شیشه', 'glass-factory', 'کارخانه شیشه', 4, ARRAY['glass factory', 'glass plant', 'شیشه سازی', 'کارخانه شیشه']::text[]),
|
||||
('pharma-health-mfg', 'Pharmaceutical Factory', 'کارخانه داروسازی', 'pharmaceutical-factory', 'کارخانه داروسازی', 1, ARRAY['drug factory', 'medicine factory', 'pharmaceutical factory', 'داروسازی', 'کارخانه دارو', 'کارخانه داروسازی']::text[]),
|
||||
('pharma-health-mfg', 'Cosmetics Factory', 'کارخانه آرایشی', 'cosmetics-factory', 'کارخانه آرایشی', 2, ARRAY['cosmetic manufacturer', 'cosmetics factory', 'آرایشی بهداشتی', 'کارخانه آرایشی']::text[]),
|
||||
('pharma-health-mfg', 'Medical Devices Factory', 'کارخانه تجهیزات پزشکی', 'medical-devices-factory', 'کارخانه تجهیزات پزشکی', 3, ARRAY['medical devices factory', 'medtech', 'تجهیزات پزشکی', 'کارخانه تجهیزات پزشکی']::text[]),
|
||||
('pharma-health-mfg', 'Supplement Factory', 'کارخانه مکمل', 'supplement-factory', 'کارخانه مکمل', 4, ARRAY['supplement factory', 'vitamin factory', 'مکمل سازی', 'کارخانه مکمل']::text[]),
|
||||
('other-factories', 'Packaging Factory', 'کارخانه بستهبندی', 'packaging-factory', 'کارخانه بستهبندی', 1, ARRAY['packaging', 'packaging factory', 'بسته بندی', 'کارخانه بستهبندی']::text[]),
|
||||
('other-factories', 'Paper & Carton Factory', 'کارخانه کاغذ و کارتن', 'paper-carton-factory', 'کارخانه کاغذ و کارتن', 2, ARRAY['carton', 'paper & carton factory', 'paper carton factory', 'paper mill', 'کارتن', 'کارخانه کاغذ و کارتن', 'کاغذ']::text[]),
|
||||
('other-factories', 'Furniture Factory', 'کارخانه مبلمان', 'furniture-factory', 'کارخانه مبلمان', 3, ARRAY['furniture factory', 'furniture manufacturer', 'مبلمان سازی', 'کارخانه مبلمان']::text[]),
|
||||
('other-factories', 'Wood Products Factory', 'کارخانه محصولات چوبی', 'wood-products-factory', 'کارخانه محصولات چوبی', 4, ARRAY['wood factory', 'wood products factory', 'چوب', 'کارخانه محصولات چوبی']::text[]),
|
||||
('other-factories', 'Printing Factory', 'چاپخانه صنعتی', 'printing-factory', 'چاپخانه صنعتی', 5, ARRAY['printing factory', 'printing plant', 'چاپ', 'چاپخانه صنعتی']::text[]),
|
||||
('contractors', 'General Contractor', 'پیمانکار عمومی', 'general-contractor', 'پیمانکار عمومی', 1, ARRAY['gc', 'general contractor', 'پیمانکار عمومی', 'پیمانکاری']::text[]),
|
||||
('contractors', 'Renovation', 'بازسازی', 'renovation', 'بازسازی', 2, ARRAY['remodel', 'renovation', 'بازسازی']::text[]),
|
||||
('contractors', 'Interior Fit-out', 'طراحی داخلی اجرایی', 'interior-fit-out', 'طراحی داخلی اجرایی', 3, ARRAY['fit out', 'interior fit out', 'interior fit-out', 'دکوراسیون داخلی', 'طراحی داخلی اجرایی']::text[]),
|
||||
('building-supplies', 'Cement Factory', 'کارخانه سیمان', 'cement-factory', 'کارخانه سیمان', 1, ARRAY['cement factory', 'cement plant', 'سیمان', 'کارخانه سیمان']::text[]),
|
||||
('building-supplies', 'Steel Plant', 'کارخانه فولاد', 'steel-plant', 'کارخانه فولاد', 2, ARRAY['steel mill', 'steel plant', 'فولاد', 'کارخانه فولاد']::text[]),
|
||||
('building-supplies', 'Electrical & Plumbing Supply', 'تأسیسات برقی و لولهکشی', 'electrical-plumbing-supply', 'تأسیسات برقی و لولهکشی', 3, ARRAY['electrical & plumbing supply', 'electrical plumbing supply', 'electrical supply', 'plumbing', 'تأسیسات', 'تأسیسات برقی و لولهکشی']::text[]),
|
||||
('farming', 'Crops', 'زراعت', 'crops', 'زراعت', 1, ARRAY['crop', 'crops', 'farm', 'زراعت']::text[]),
|
||||
('farming', 'Livestock', 'دامپروری', 'livestock', 'دامپروری', 2, ARRAY['cattle', 'livestock', 'poultry', 'دام', 'دامپروری', 'مرغداری']::text[]),
|
||||
('farming', 'Greenhouse', 'گلخانه', 'greenhouse', 'گلخانه', 3, ARRAY['greenhouse', 'greenhouse farm', 'گلخانه']::text[]),
|
||||
('mining-extraction', 'Minerals', 'مواد معدنی', 'minerals', 'مواد معدنی', 1, ARRAY['mineral', 'minerals', 'معدنی', 'مواد معدنی']::text[]),
|
||||
('mining-extraction', 'Stone & Quarry', 'سنگ و معدن سنگ', 'stone-quarry', 'سنگ و معدن سنگ', 2, ARRAY['quarry', 'stone', 'stone & quarry', 'stone quarry', 'سنگ و معدن سنگ', 'سنگبری']::text[]),
|
||||
('energy', 'Solar & Renewables', 'خورشیدی و تجدیدپذیر', 'solar-renewables', 'خورشیدی و تجدیدپذیر', 1, ARRAY['renewable', 'solar', 'solar & renewables', 'solar renewables', 'خورشیدی و تجدیدپذیر', 'پنل خورشیدی']::text[]),
|
||||
('energy', 'Oil & Gas Related', 'نفت و گاز', 'oil-gas-related', 'نفت و گاز', 2, ARRAY['gas', 'oil', 'oil & gas related', 'oil gas related', 'نفت', 'نفت و گاز', 'گاز']::text[]),
|
||||
('energy', 'Power Equipment', 'تجهیزات برق', 'power-equipment', 'تجهیزات برق', 3, ARRAY['power equipment', 'تجهیزات برق', 'تجهیزات قدرت']::text[]),
|
||||
('industrial-services', 'Logistics Support', 'پشتیبانی لجستیک', 'logistics-support', 'پشتیبانی لجستیک', 1, ARRAY['logistics', 'logistics support', 'لجستیک', 'پشتیبانی لجستیک']::text[]),
|
||||
('industrial-services', 'Industrial Maintenance', 'نگهداری صنعتی', 'industrial-maintenance', 'نگهداری صنعتی', 2, ARRAY['industrial maintenance', 'maintenance', 'نگهداری', 'نگهداری صنعتی']::text[]),
|
||||
('business-consulting', 'Management Consulting', 'مشاوره مدیریت', 'management-consulting', 'مشاوره مدیریت', 1, ARRAY['management consultant', 'management consulting', 'مشاور مدیریت', 'مشاوره مدیریت']::text[]),
|
||||
('business-consulting', 'Marketing & Branding', 'بازاریابی و برندینگ', 'marketing-branding', 'بازاریابی و برندینگ', 2, ARRAY['branding', 'marketing & branding', 'marketing branding', 'بازاریابی', 'بازاریابی و برندینگ', 'برندینگ']::text[]),
|
||||
('business-consulting', 'Legal & Accounting', 'حقوقی و حسابداری', 'legal-accounting', 'حقوقی و حسابداری', 3, ARRAY['accountant', 'lawyer', 'legal & accounting', 'legal accounting', 'حسابدار', 'حقوقی و حسابداری', 'وکیل']::text[]),
|
||||
('design-creative', 'Graphic Design', 'طراحی گرافیک', 'graphic-design', 'طراحی گرافیک', 1, ARRAY['graphic design', 'graphic designer', 'طراحی گرافیک', 'گرافیک']::text[]),
|
||||
('design-creative', 'Architecture & Interior', 'معماری و داخلی', 'architecture-interior', 'معماری و داخلی', 2, ARRAY['architect', 'architecture & interior', 'architecture interior', 'interior design', 'طراح داخلی', 'معمار', 'معماری و داخلی']::text[]),
|
||||
('design-creative', 'Photography & Video', 'عکاسی و فیلم', 'photography-video', 'عکاسی و فیلم', 3, ARRAY['photographer', 'photography & video', 'photography video', 'videographer', 'عکاس', 'عکاسی و فیلم', 'فیلمبردار']::text[]),
|
||||
('education-training', 'Training Centers', 'مراکز آموزشی', 'training-centers', 'مراکز آموزشی', 1, ARRAY['training center', 'training centers', 'آموزشگاه', 'مراکز آموزشی']::text[]),
|
||||
('education-training', 'Online Courses', 'دورههای آنلاین', 'online-courses', 'دورههای آنلاین', 2, ARRAY['elearning', 'online course', 'online courses', 'دوره آنلاین', 'دورههای آنلاین']::text[]),
|
||||
('education-training', 'Language Schools', 'آموزشگاه زبان', 'language-schools', 'آموزشگاه زبان', 3, ARRAY['language school', 'language schools', 'آموزشگاه زبان', 'زبان']::text[]),
|
||||
('software-it', 'Software Development', 'توسعه نرمافزار', 'software-development', 'توسعه نرمافزار', 1, ARRAY['dev shop', 'software company', 'software development', 'برنامه نویسی', 'توسعه نرمافزار']::text[]),
|
||||
('software-it', 'SaaS & Platforms', 'نرمافزار ابری و پلتفرم', 'saas-platforms', 'نرمافزار ابری و پلتفرم', 2, ARRAY['platform', 'saas', 'saas & platforms', 'saas platforms', 'ابری', 'نرمافزار ابری و پلتفرم']::text[]),
|
||||
('software-it', 'IT Support', 'پشتیبانی آیتی', 'it-support', 'پشتیبانی آیتی', 3, ARRAY['helpdesk', 'it support', 'پشتیبانی', 'پشتیبانی آیتی']::text[]),
|
||||
('digital-marketing', 'SEO & Ads', 'سئو و تبلیغات', 'seo-ads', 'سئو و تبلیغات', 1, ARRAY['ads', 'google ads', 'seo', 'seo & ads', 'seo ads', 'سئو', 'سئو و تبلیغات']::text[]),
|
||||
('digital-marketing', 'Social Media', 'شبکههای اجتماعی', 'social-media', 'شبکههای اجتماعی', 2, ARRAY['instagram marketing', 'social media', 'سوشال', 'شبکههای اجتماعی']::text[]),
|
||||
('digital-marketing', 'Content Production', 'تولید محتوا', 'content-production', 'تولید محتوا', 3, ARRAY['content', 'content production', 'تولید محتوا', 'محتوا']::text[]),
|
||||
('beauty-care-services', 'Hair Salon', 'آرایشگاه', 'hair-salon', 'آرایشگاه', 1, ARRAY['barber', 'hair salon', 'hairdresser', 'آرایشگاه', 'آرایشگاه زنانه', 'آرایشگاه مردانه']::text[]),
|
||||
('beauty-care-services', 'Spa & Wellness', 'اسپا و سلامت', 'spa-wellness', 'اسپا و سلامت', 2, ARRAY['massage', 'spa', 'spa & wellness', 'spa wellness', 'اسپا', 'اسپا و سلامت']::text[]),
|
||||
('beauty-care-services', 'Clinic Services', 'کلینیک زیبایی', 'clinic-services-beauty', 'کلینیک زیبایی', 3, ARRAY['beauty clinic', 'clinic services', 'clinic services beauty', 'کلینیک زیبایی']::text[]),
|
||||
('home-services', 'Cleaning', 'نظافت', 'cleaning', 'نظافت', 1, ARRAY['cleaner', 'cleaning', 'شرکت نظافتی', 'نظافت', 'نظافتچی']::text[]),
|
||||
('home-services', 'Repair & Maintenance', 'تعمیرات', 'repair-maintenance', 'تعمیرات', 2, ARRAY['handyman', 'repair & maintenance', 'repair maintenance', 'تعمیرات', 'تعمیرات منزل']::text[]),
|
||||
('home-services', 'Moving & Logistics', 'اسبابکشی و حمل', 'moving-logistics', 'اسبابکشی و حمل', 3, ARRAY['moving', 'moving & logistics', 'moving logistics', 'relocation', 'اسباب کشی', 'اسبابکشی و حمل', 'باربری']::text[]),
|
||||
('hospitality', 'Restaurants & Cafés', 'رستوران و کافه', 'restaurants-cafes', 'رستوران و کافه', 1, ARRAY['cafe', 'restaurant', 'restaurants & cafés', 'restaurants cafes', 'رستوران', 'رستوران و کافه', 'کافه']::text[]),
|
||||
('hospitality', 'Catering', 'کترینگ', 'catering', 'کترینگ', 2, ARRAY['caterer', 'catering', 'کترینگ']::text[]),
|
||||
('hospitality', 'Hotels & Stays', 'هتل و اقامتگاه', 'hotels-stays', 'هتل و اقامتگاه', 3, ARRAY['hotel', 'hotels & stays', 'hotels stays', 'اقامتگاه', 'هتل', 'هتل و اقامتگاه']::text[]),
|
||||
('doctors-specialists', 'General Practitioner', 'پزشک عمومی', 'general-practitioner', 'پزشک عمومی', 1, ARRAY['family doctor', 'general practitioner', 'gp', 'دکتر عمومی', 'پزشک عمومی']::text[]),
|
||||
('doctors-specialists', 'Internal Medicine', 'داخلی', 'internal-medicine', 'داخلی', 2, ARRAY['internal medicine', 'internist', 'داخلی']::text[]),
|
||||
('doctors-specialists', 'Cardiology', 'قلب و عروق', 'cardiology', 'قلب و عروق', 3, ARRAY['cardiologist', 'cardiology', 'heart doctor', 'قلب', 'قلب و عروق', 'متخصص قلب']::text[]),
|
||||
('doctors-specialists', 'ENT (Otolaryngology)', 'گوش و حلق و بینی', 'ent-otolaryngology', 'گوش و حلق و بینی', 4, ARRAY['ear nose throat', 'ent', 'ent (otolaryngology)', 'ent otolaryngology', 'متخصص گوش', 'گوش حلق بینی', 'گوش و حلق و بینی']::text[]),
|
||||
('doctors-specialists', 'Dermatology', 'پوست', 'dermatology', 'پوست', 5, ARRAY['dermatologist', 'dermatology', 'skin doctor', 'متخصص پوست', 'پوست']::text[]),
|
||||
('doctors-specialists', 'Ophthalmology', 'چشم', 'ophthalmology', 'چشم', 6, ARRAY['eye doctor', 'ophthalmologist', 'ophthalmology', 'چشم', 'چشم پزشک']::text[]),
|
||||
('doctors-specialists', 'Orthopedics', 'ارتوپدی', 'orthopedics', 'ارتوپدی', 7, ARRAY['bone doctor', 'orthopedics', 'orthopedist', 'ارتوپد', 'ارتوپدی', 'استخوان']::text[]),
|
||||
('doctors-specialists', 'Neurology', 'مغز و اعصاب', 'neurology', 'مغز و اعصاب', 8, ARRAY['neurologist', 'neurology', 'مغز اعصاب', 'مغز و اعصاب']::text[]),
|
||||
('doctors-specialists', 'Neurosurgery', 'جراحی مغز و اعصاب', 'neurosurgery', 'جراحی مغز و اعصاب', 9, ARRAY['neurosurgeon', 'neurosurgery', 'جراح مغز', 'جراحی مغز و اعصاب']::text[]),
|
||||
('doctors-specialists', 'General Surgery', 'جراحی عمومی', 'general-surgery', 'جراحی عمومی', 10, ARRAY['general surgery', 'surgeon', 'جراح', 'جراحی عمومی']::text[]),
|
||||
('doctors-specialists', 'Plastic & Aesthetic Surgery', 'جراحی پلاستیک و زیبایی', 'plastic-aesthetic-surgery', 'جراحی پلاستیک و زیبایی', 11, ARRAY['cosmetic surgery', 'plastic & aesthetic surgery', 'plastic aesthetic surgery', 'plastic surgery', 'جراحی زیبایی', 'جراحی پلاستیک و زیبایی', 'پلاستیک']::text[]),
|
||||
('doctors-specialists', 'Obstetrics & Gynecology', 'زنان و زایمان', 'obstetrics-gynecology', 'زنان و زایمان', 12, ARRAY['gynecologist', 'obgyn', 'obstetrics & gynecology', 'obstetrics gynecology', 'زایمان', 'زنان', 'زنان و زایمان']::text[]),
|
||||
('doctors-specialists', 'Infertility & IVF', 'ناباروری و IVF', 'infertility-ivf', 'ناباروری و IVF', 13, ARRAY['fertility', 'infertility & ivf', 'infertility ivf', 'ivf', 'ناباروری', 'ناباروری و ivf', 'نازایی']::text[]),
|
||||
('doctors-specialists', 'Pediatrics', 'اطفال', 'pediatrics', 'اطفال', 14, ARRAY['kids doctor', 'pediatrician', 'pediatrics', 'اطفال', 'کودکان']::text[]),
|
||||
('doctors-specialists', 'Urology', 'اورولوژی', 'urology', 'اورولوژی', 15, ARRAY['urologist', 'urology', 'اورولوژی']::text[]),
|
||||
('doctors-specialists', 'Gastroenterology', 'گوارش', 'gastroenterology', 'گوارش', 16, ARRAY['gastro', 'gastroenterology', 'گوارش']::text[]),
|
||||
('doctors-specialists', 'Endocrinology', 'غدد', 'endocrinology', 'غدد', 17, ARRAY['endocrinologist', 'endocrinology', 'دیابت', 'غدد']::text[]),
|
||||
('doctors-specialists', 'Pulmonology', 'ریه', 'pulmonology', 'ریه', 18, ARRAY['lung doctor', 'pulmonology', 'تنفسی', 'ریه']::text[]),
|
||||
('doctors-specialists', 'Nephrology', 'کلیه', 'nephrology', 'کلیه', 19, ARRAY['nephrologist', 'nephrology', 'کلیه']::text[]),
|
||||
('doctors-specialists', 'Rheumatology', 'روماتولوژی', 'rheumatology', 'روماتولوژی', 20, ARRAY['rheumatologist', 'rheumatology', 'روماتولوژی', 'روماتیسم']::text[]),
|
||||
('doctors-specialists', 'Hematology & Oncology', 'خون و سرطان', 'hematology-oncology', 'خون و سرطان', 21, ARRAY['cancer', 'hematology & oncology', 'hematology oncology', 'oncologist', 'خون', 'خون و سرطان', 'سرطان']::text[]),
|
||||
('doctors-specialists', 'Infectious Disease', 'عفونی', 'infectious-disease', 'عفونی', 22, ARRAY['infectious', 'infectious disease', 'عفونی']::text[]),
|
||||
('doctors-specialists', 'Allergy & Immunology', 'آلرژی و ایمونولوژی', 'allergy-immunology', 'آلرژی و ایمونولوژی', 23, ARRAY['allergy', 'allergy & immunology', 'allergy immunology', 'آلرژی', 'آلرژی و ایمونولوژی']::text[]),
|
||||
('doctors-specialists', 'Psychiatry', 'روانپزشکی', 'psychiatry', 'روانپزشکی', 24, ARRAY['psychiatrist', 'psychiatry', 'روانپزشک', 'روانپزشکی']::text[]),
|
||||
('doctors-specialists', 'Psychology', 'روانشناسی', 'psychology', 'روانشناسی', 25, ARRAY['counselor', 'psychologist', 'psychology', 'روانشناس', 'روانشناسی']::text[]),
|
||||
('doctors-specialists', 'Dentistry', 'دندانپزشکی', 'dentistry', 'دندانپزشکی', 26, ARRAY['dental', 'dentist', 'dentistry', 'دندان', 'دندانپزشک', 'دندانپزشکی']::text[]),
|
||||
('doctors-specialists', 'Orthodontics', 'ارتودنسی', 'orthodontics', 'ارتودنسی', 27, ARRAY['orthodontics', 'orthodontist', 'ارتودنسی']::text[]),
|
||||
('doctors-specialists', 'Oral & Maxillofacial Surgery', 'جراحی فک و صورت', 'oral-maxillofacial-surgery', 'جراحی فک و صورت', 28, ARRAY['oral & maxillofacial surgery', 'oral maxillofacial surgery', 'oral surgery', 'جراحی فک', 'جراحی فک و صورت']::text[]),
|
||||
('doctors-specialists', 'Radiology', 'رادیولوژی', 'radiology', 'رادیولوژی', 29, ARRAY['radiologist', 'radiology', 'رادیولوژی', 'عکسبرداری']::text[]),
|
||||
('doctors-specialists', 'Nuclear Medicine', 'پزشکی هستهای', 'nuclear-medicine', 'پزشکی هستهای', 30, ARRAY['nuclear medicine', 'پزشکی هسته ای', 'پزشکی هستهای']::text[]),
|
||||
('doctors-specialists', 'Anesthesiology', 'بیهوشی', 'anesthesiology', 'بیهوشی', 31, ARRAY['anesthesiologist', 'anesthesiology', 'بیهوشی']::text[]),
|
||||
('doctors-specialists', 'Emergency Medicine', 'اورژانس', 'emergency-medicine', 'اورژانس', 32, ARRAY['emergency doctor', 'emergency medicine', 'اورژانس']::text[]),
|
||||
('doctors-specialists', 'Physical Medicine & Rehabilitation', 'طب فیزیکی و توانبخشی', 'physical-medicine-rehab', 'طب فیزیکی و توانبخشی', 33, ARRAY['physical medicine & rehabilitation', 'physical medicine rehab', 'pmr', 'rehab', 'توانبخشی', 'طب فیزیکی', 'طب فیزیکی و توانبخشی']::text[]),
|
||||
('doctors-specialists', 'Traditional Iranian Medicine', 'طب سنتی ایرانی', 'traditional-iranian-medicine', 'طب سنتی ایرانی', 34, ARRAY['persian medicine', 'traditional iranian medicine', 'traditional medicine', 'طب سنتی', 'طب سنتی ایرانی', 'گیاهی']::text[]),
|
||||
('doctors-specialists', 'Nutrition & Dietetics', 'تغذیه', 'nutrition-dietetics', 'تغذیه', 35, ARRAY['dietitian', 'nutrition & dietetics', 'nutrition dietetics', 'nutritionist', 'تغذیه', 'رژیم']::text[]),
|
||||
('doctors-specialists', 'Occupational Medicine', 'طب کار', 'occupational-medicine', 'طب کار', 36, ARRAY['occupational health', 'occupational medicine', 'طب کار']::text[]),
|
||||
('clinics-centers', 'Specialty Clinics', 'کلینیک تخصصی', 'specialty-clinics', 'کلینیک تخصصی', 1, ARRAY['specialty clinic', 'specialty clinics', 'کلینیک تخصصی']::text[]),
|
||||
('clinics-centers', 'Polyclinics', 'درمانگاه', 'polyclinics', 'درمانگاه', 2, ARRAY['polyclinic', 'polyclinics', 'درمانگاه']::text[]),
|
||||
('clinics-centers', 'Dental Clinics', 'کلینیک دندانپزشکی', 'dental-clinics', 'کلینیک دندانپزشکی', 3, ARRAY['dental clinic', 'dental clinics', 'کلینیک دندان', 'کلینیک دندانپزشکی']::text[]),
|
||||
('clinics-centers', 'Aesthetic Clinics', 'کلینیک زیبایی', 'aesthetic-clinics', 'کلینیک زیبایی', 4, ARRAY['aesthetic clinic', 'aesthetic clinics', 'کلینیک زیبایی']::text[]),
|
||||
('clinics-centers', 'Infertility Centers', 'مراکز ناباروری', 'infertility-centers', 'مراکز ناباروری', 5, ARRAY['infertility centers', 'ivf center', 'مراکز ناباروری', 'مرکز ناباروری']::text[]),
|
||||
('hospitals-labs', 'Hospitals', 'بیمارستان', 'hospitals', 'بیمارستان', 1, ARRAY['hospital', 'hospitals', 'بیمارستان']::text[]),
|
||||
('hospitals-labs', 'Diagnostic Labs', 'آزمایشگاه تشخیصی', 'diagnostic-labs', 'آزمایشگاه تشخیصی', 2, ARRAY['diagnostic labs', 'lab', 'laboratory', 'آزمایشگاه', 'آزمایشگاه تشخیصی']::text[]),
|
||||
('hospitals-labs', 'Imaging Centers', 'مراکز تصویربرداری', 'imaging-centers', 'مراکز تصویربرداری', 3, ARRAY['ct scan', 'imaging centers', 'mri', 'تصویربرداری', 'سونوگرافی', 'مراکز تصویربرداری']::text[]),
|
||||
('care-support', 'Physiotherapy', 'فیزیوتراپی', 'physiotherapy', 'فیزیوتراپی', 1, ARRAY['physical therapy', 'physio', 'physiotherapy', 'فیزیوتراپی']::text[]),
|
||||
('care-support', 'Nursing & Home Care', 'پرستاری و مراقبت در منزل', 'nursing-home-care', 'پرستاری و مراقبت در منزل', 2, ARRAY['home care', 'nurse', 'nursing & home care', 'nursing home care', 'مراقبت در منزل', 'پرستاری و مراقبت در منزل']::text[]),
|
||||
('care-support', 'Midwifery', 'مامایی', 'midwifery', 'مامایی', 3, ARRAY['midwife', 'midwifery', 'ماما', 'مامایی']::text[]),
|
||||
('care-support', 'Pharmacy Services', 'خدمات داروخانه', 'pharmacy-services', 'خدمات داروخانه', 4, ARRAY['drug store', 'drugstore', 'pharmacy service', 'pharmacy services', 'خدمات داروخانه', 'داروخانه']::text[]),
|
||||
('vehicle-sales', 'Car Dealership', 'نمایندگی خودرو', 'car-dealership', 'نمایندگی خودرو', 1, ARRAY['car dealer', 'car dealership', 'dealership', 'نمایندگی', 'نمایندگی خودرو']::text[]),
|
||||
('vehicle-sales', 'Motorcycle Dealer', 'فروش موتورسیکلت', 'motorcycle-dealer', 'فروش موتورسیکلت', 2, ARRAY['bike dealer', 'motorcycle dealer', 'فروش موتورسیکلت', 'موتور']::text[]),
|
||||
('repair-parts', 'Auto Repair', 'تعمیرگاه خودرو', 'auto-repair', 'تعمیرگاه خودرو', 1, ARRAY['auto repair', 'garage', 'mechanic', 'تعمیرگاه', 'تعمیرگاه خودرو', 'مکانیکی']::text[]),
|
||||
('repair-parts', 'Auto Parts', 'لوازم یدکی', 'auto-parts', 'لوازم یدکی', 2, ARRAY['auto parts', 'spare parts', 'قطعات', 'لوازم یدکی', 'یدکی']::text[]),
|
||||
('repair-parts', 'Car Wash & Detailing', 'کارواش و دیتیلینگ', 'car-wash-detailing', 'کارواش و دیتیلینگ', 3, ARRAY['car wash', 'car wash & detailing', 'car wash detailing', 'detailing', 'کارواش', 'کارواش و دیتیلینگ']::text[])
|
||||
) AS v(parent_slug, name, name_fa, slug, description, sort_order, tags)
|
||||
JOIN categories p ON p.slug = v.parent_slug
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = EXCLUDED.parent_id,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
|
||||
-- Seeded 237 categories (L1=3, L2=14, L3=43, L4=177)
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Add Other catch-all to AriyaWay marketplace categories
|
||||
ALTER TABLE categories ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}';
|
||||
|
||||
INSERT INTO categories (parent_id, name, name_fa, slug, sort_order, tags, is_active)
|
||||
VALUES (
|
||||
NULL,
|
||||
'Other',
|
||||
'سایر',
|
||||
'other',
|
||||
99,
|
||||
ARRAY['other', 'misc', 'miscellaneous', 'uncategorized', 'general', 'سایر', 'متفرقه', 'عمومی']::text[],
|
||||
TRUE
|
||||
)
|
||||
ON CONFLICT (slug) DO UPDATE SET
|
||||
parent_id = NULL,
|
||||
name = EXCLUDED.name,
|
||||
name_fa = EXCLUDED.name_fa,
|
||||
sort_order = EXCLUDED.sort_order,
|
||||
tags = EXCLUDED.tags,
|
||||
is_active = TRUE,
|
||||
updated_at = NOW();
|
||||
@@ -0,0 +1,14 @@
|
||||
-- Slim marketplace businesses: Meshkee owns profile content.
|
||||
-- AriyaWay keeps listing fields + display name only.
|
||||
|
||||
ALTER TABLE businesses
|
||||
DROP COLUMN IF EXISTS website_url,
|
||||
DROP COLUMN IF EXISTS tagline,
|
||||
DROP COLUMN IF EXISTS description,
|
||||
DROP COLUMN IF EXISTS logo_url,
|
||||
DROP COLUMN IF EXISTS hero_url,
|
||||
DROP COLUMN IF EXISTS province,
|
||||
DROP COLUMN IF EXISTS country,
|
||||
DROP COLUMN IF EXISTS telephone,
|
||||
DROP COLUMN IF EXISTS since_year,
|
||||
DROP COLUMN IF EXISTS synced_at;
|
||||
@@ -0,0 +1,12 @@
|
||||
-- Minimum marketplace listing: Meshkee owns name, categories, and profile.
|
||||
-- AriyaWay/IranBay keeps only listing controls.
|
||||
|
||||
ALTER TABLE businesses DROP CONSTRAINT IF EXISTS businesses_primary_category_id_fkey;
|
||||
|
||||
DROP TABLE IF EXISTS business_categories;
|
||||
|
||||
ALTER TABLE businesses
|
||||
DROP COLUMN IF EXISTS primary_category_id,
|
||||
DROP COLUMN IF EXISTS name;
|
||||
|
||||
DROP TABLE IF EXISTS categories;
|
||||
@@ -0,0 +1,10 @@
|
||||
-- Homepage media config for the public website (slider, banners, categories, about).
|
||||
|
||||
CREATE TABLE IF NOT EXISTS website_settings (
|
||||
key TEXT PRIMARY KEY,
|
||||
value JSONB NOT NULL DEFAULT '{}'::jsonb,
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
COMMENT ON TABLE website_settings IS
|
||||
'Key/value JSON settings for marketplace website content.';
|
||||
@@ -0,0 +1,31 @@
|
||||
-- Website blog posts managed from the superadmin dashboard.
|
||||
|
||||
CREATE TABLE IF NOT EXISTS website_blog_categories (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
name VARCHAR(120) NOT NULL,
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS website_blog_categories_name_unique
|
||||
ON website_blog_categories (name);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS website_blog_posts (
|
||||
id BIGSERIAL PRIMARY KEY,
|
||||
title VARCHAR(200) NOT NULL,
|
||||
abstract TEXT,
|
||||
main_text_html TEXT,
|
||||
image_url TEXT,
|
||||
category_id BIGINT NOT NULL REFERENCES website_blog_categories (id) ON DELETE RESTRICT,
|
||||
tags TEXT[] NOT NULL DEFAULT '{}',
|
||||
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_website_blog_posts_created_at
|
||||
ON website_blog_posts (created_at DESC);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_website_blog_posts_category_id
|
||||
ON website_blog_posts (category_id);
|
||||
|
||||
COMMENT ON TABLE website_blog_posts IS
|
||||
'IranBay website blog posts edited from the superadmin dashboard.';
|
||||
@@ -0,0 +1,21 @@
|
||||
-- Blog categories can nest under a parent.
|
||||
|
||||
ALTER TABLE website_blog_categories
|
||||
ADD COLUMN IF NOT EXISTS parent_id BIGINT;
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
ALTER TABLE website_blog_categories
|
||||
ADD CONSTRAINT website_blog_categories_parent_id_fkey
|
||||
FOREIGN KEY (parent_id) REFERENCES website_blog_categories (id) ON DELETE RESTRICT;
|
||||
EXCEPTION
|
||||
WHEN duplicate_object THEN NULL;
|
||||
END $$;
|
||||
|
||||
DROP INDEX IF EXISTS website_blog_categories_name_unique;
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_website_blog_categories_parent_id
|
||||
ON website_blog_categories (parent_id);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS website_blog_categories_parent_name_unique
|
||||
ON website_blog_categories (COALESCE(parent_id, 0), lower(name));
|
||||
@@ -0,0 +1,5 @@
|
||||
-- Blog categories store the same details shown on the business category form.
|
||||
|
||||
ALTER TABLE website_blog_categories
|
||||
ADD COLUMN IF NOT EXISTS name_fa VARCHAR(120),
|
||||
ADD COLUMN IF NOT EXISTS description TEXT;
|
||||
Executable
+19
@@ -0,0 +1,19 @@
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
|
||||
CONTAINER="${POSTGRES_CONTAINER:-ariyaway-postgres}"
|
||||
DB_USER="${POSTGRES_USER:-ariyaway}"
|
||||
DB_NAME="${POSTGRES_DB:-ariyaway}"
|
||||
MAX_ATTEMPTS="${POSTGRES_WAIT_ATTEMPTS:-30}"
|
||||
|
||||
for ((i = 1; i <= MAX_ATTEMPTS; i++)); do
|
||||
if docker exec "$CONTAINER" pg_isready -U "$DB_USER" -d "$DB_NAME" >/dev/null 2>&1; then
|
||||
echo "Postgres is ready."
|
||||
exit 0
|
||||
fi
|
||||
echo "Waiting for Postgres ($i/$MAX_ATTEMPTS)…"
|
||||
sleep 1
|
||||
done
|
||||
|
||||
echo "Postgres did not become ready in time." >&2
|
||||
exit 1
|
||||
@@ -0,0 +1,25 @@
|
||||
services:
|
||||
postgres:
|
||||
image: postgres:16-alpine
|
||||
container_name: ariyaway-postgres
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "127.0.0.1:${POSTGRES_PORT:-5433}:5432"
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER:-ariyaway}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-ariyaway_secret}
|
||||
POSTGRES_DB: ${POSTGRES_DB:-ariyaway}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test:
|
||||
[
|
||||
"CMD-SHELL",
|
||||
"pg_isready -U ${POSTGRES_USER:-ariyaway} -d ${POSTGRES_DB:-ariyaway}",
|
||||
]
|
||||
interval: 5s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"$schema": "https://json.schemastore.org/nest-cli",
|
||||
"collection": "@nestjs/schematics",
|
||||
"sourceRoot": "src",
|
||||
"compilerOptions": {
|
||||
"deleteOutDir": true
|
||||
}
|
||||
}
|
||||
Generated
+4060
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,34 @@
|
||||
{
|
||||
"name": "marketplace-api",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"build": "nest build",
|
||||
"start": "nest start",
|
||||
"start:dev": "nest start --watch",
|
||||
"start:prod": "node dist/main",
|
||||
"prisma:generate": "prisma generate",
|
||||
"db:migrate": "./database/migrate.sh",
|
||||
"bootstrap:iranbay": "node scripts/bootstrap-iranbay-platform.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@nestjs/common": "^11.0.0",
|
||||
"@nestjs/config": "^4.0.0",
|
||||
"@nestjs/core": "^11.0.0",
|
||||
"@nestjs/platform-express": "^11.0.0",
|
||||
"@prisma/client": "^6.0.0",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^11.0.0",
|
||||
"@nestjs/schematics": "^11.0.0",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/multer": "^2.3.0",
|
||||
"@types/node": "^22.0.0",
|
||||
"prisma": "^6.0.0",
|
||||
"typescript": "^5.7.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "postgresql"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
/// Marketplace listing only. Name, categories, and profile live in Meshkee.
|
||||
model Business {
|
||||
id BigInt @id @default(autoincrement())
|
||||
meshkeeBusinessId BigInt @unique(map: "businesses_meshkee_business_id_unique") @map("meshkee_business_id")
|
||||
slug String @unique(map: "businesses_slug_unique") @db.VarChar(100)
|
||||
status String @default("draft") @db.VarChar(32)
|
||||
isFeatured Boolean @default(false) @map("is_featured")
|
||||
sortWeight Int @default(0) @map("sort_weight")
|
||||
publishedAt DateTime? @map("published_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)
|
||||
|
||||
@@index([status, isFeatured, sortWeight(sort: Desc)], map: "idx_businesses_status_featured")
|
||||
@@map("businesses")
|
||||
}
|
||||
|
||||
/// JSON settings for the public website (homepage media, etc.).
|
||||
model WebsiteSetting {
|
||||
key String @id
|
||||
value Json
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@map("website_settings")
|
||||
}
|
||||
|
||||
/// Categories for the IranBay website blog (superadmin).
|
||||
model WebsiteBlogCategory {
|
||||
id BigInt @id @default(autoincrement())
|
||||
name String @db.VarChar(120)
|
||||
nameFa String? @map("name_fa") @db.VarChar(120)
|
||||
description String? @db.Text
|
||||
parentId BigInt? @map("parent_id")
|
||||
parent WebsiteBlogCategory? @relation("WebsiteBlogCategoryTree", fields: [parentId], references: [id], onDelete: Restrict)
|
||||
children WebsiteBlogCategory[] @relation("WebsiteBlogCategoryTree")
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
posts WebsiteBlogPost[]
|
||||
|
||||
@@index([parentId], map: "idx_website_blog_categories_parent_id")
|
||||
@@map("website_blog_categories")
|
||||
}
|
||||
|
||||
/// Posts for the IranBay website blog (superadmin).
|
||||
model WebsiteBlogPost {
|
||||
id BigInt @id @default(autoincrement())
|
||||
title String @db.VarChar(200)
|
||||
abstract String? @db.Text
|
||||
mainTextHtml String? @map("main_text_html") @db.Text
|
||||
imageUrl String? @map("image_url") @db.Text
|
||||
categoryId BigInt @map("category_id")
|
||||
category WebsiteBlogCategory @relation(fields: [categoryId], references: [id], onDelete: Restrict)
|
||||
tags String[] @default([])
|
||||
createdAt DateTime @default(now()) @map("created_at") @db.Timestamptz(6)
|
||||
updatedAt DateTime @default(now()) @updatedAt @map("updated_at") @db.Timestamptz(6)
|
||||
|
||||
@@index([createdAt(sort: Desc)], map: "idx_website_blog_posts_created_at")
|
||||
@@index([categoryId], map: "idx_website_blog_posts_category_id")
|
||||
@@map("website_blog_posts")
|
||||
}
|
||||
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Bootstrap the IranBay platform tenant in Meshkee.
|
||||
*
|
||||
* This creates ONE Meshkee business used only for IranBay auth
|
||||
* (customer accounts via domain iranbay.com).
|
||||
*
|
||||
* Producer shops that register on IranBay are SEPARATE Meshkee businesses
|
||||
* (created later via admin / register-shop) — do not reuse this tenant.
|
||||
*
|
||||
* Usage:
|
||||
* MESHKEE_API_URL=https://api.meshkee.com/api/v1 \
|
||||
* MESHKEE_API_TOKEN='Bearer …' \ # superadmin access token
|
||||
* node scripts/bootstrap-iranbay-platform.mjs
|
||||
*
|
||||
* Or login instead of token:
|
||||
* MESHKEE_SUPERADMIN_CELL=+98… MESHKEE_SUPERADMIN_PASSWORD=… node …
|
||||
*/
|
||||
|
||||
const API =
|
||||
(process.env.MESHKEE_API_URL || "https://api.meshkee.com/api/v1").replace(
|
||||
/\/$/,
|
||||
"",
|
||||
);
|
||||
const HOST = (
|
||||
process.env.IRANBAY_PLATFORM_DOMAIN || "iranbay.com"
|
||||
).toLowerCase();
|
||||
const SLUG = process.env.IRANBAY_PLATFORM_SLUG || "iranbay";
|
||||
|
||||
async function request(path, { method = "GET", token, body } = {}) {
|
||||
const headers = { "Content-Type": "application/json" };
|
||||
if (token) headers.Authorization = token.startsWith("Bearer ")
|
||||
? token
|
||||
: `Bearer ${token}`;
|
||||
|
||||
const res = await fetch(`${API}${path}`, {
|
||||
method,
|
||||
headers,
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
const payload = await res.json().catch(() => null);
|
||||
if (!res.ok) {
|
||||
const message =
|
||||
typeof payload?.message === "string"
|
||||
? payload.message
|
||||
: Array.isArray(payload?.message)
|
||||
? payload.message.join(", ")
|
||||
: `HTTP ${res.status}`;
|
||||
const err = new Error(message);
|
||||
err.status = res.status;
|
||||
err.payload = payload;
|
||||
throw err;
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
|
||||
async function resolveToken() {
|
||||
const raw = process.env.MESHKEE_API_TOKEN?.trim();
|
||||
if (raw) return raw.startsWith("Bearer ") ? raw : `Bearer ${raw}`;
|
||||
|
||||
const cell = process.env.MESHKEE_SUPERADMIN_CELL?.trim();
|
||||
const password = process.env.MESHKEE_SUPERADMIN_PASSWORD;
|
||||
if (!cell || !password) {
|
||||
throw new Error(
|
||||
"Set MESHKEE_API_TOKEN or MESHKEE_SUPERADMIN_CELL + MESHKEE_SUPERADMIN_PASSWORD",
|
||||
);
|
||||
}
|
||||
|
||||
const data = await request("/auth/login", {
|
||||
method: "POST",
|
||||
body: { cellNumber: cell, password },
|
||||
});
|
||||
if (!data.accessToken) throw new Error("Login did not return accessToken");
|
||||
return `Bearer ${data.accessToken}`;
|
||||
}
|
||||
|
||||
async function domainExists() {
|
||||
const res = await fetch(`${API}/tenants/${HOST}/categories`);
|
||||
return res.status !== 404;
|
||||
}
|
||||
|
||||
async function main() {
|
||||
console.log(`Meshkee API: ${API}`);
|
||||
console.log(`Platform domain: ${HOST}`);
|
||||
|
||||
if (await domainExists()) {
|
||||
console.log(`✓ Domain ${HOST} already resolves — nothing to do.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const token = await resolveToken();
|
||||
|
||||
const categories = await request("/business-categories", { token });
|
||||
const items = categories.items ?? [];
|
||||
const other =
|
||||
items.find((c) => String(c.slug) === "other") ??
|
||||
items[0];
|
||||
if (!other) {
|
||||
throw new Error("No Meshkee business categories found (need at least one).");
|
||||
}
|
||||
|
||||
console.log(`Creating Meshkee business slug=${SLUG}…`);
|
||||
const ownerCell =
|
||||
process.env.MESHKEE_SUPERADMIN_CELL?.trim() ||
|
||||
process.env.IRANBAY_PLATFORM_OWNER_CELL?.trim();
|
||||
const ownerPassword =
|
||||
process.env.MESHKEE_SUPERADMIN_PASSWORD ||
|
||||
process.env.IRANBAY_PLATFORM_OWNER_PASSWORD;
|
||||
|
||||
if (!ownerCell || !ownerPassword) {
|
||||
throw new Error(
|
||||
"Need owner cell/password to create the platform business (use MESHKEE_SUPERADMIN_* or IRANBAY_PLATFORM_OWNER_*).",
|
||||
);
|
||||
}
|
||||
|
||||
let business;
|
||||
try {
|
||||
business = await request("/businesses", {
|
||||
method: "POST",
|
||||
token,
|
||||
body: {
|
||||
name: "IranBay",
|
||||
nameFa: "ایرانبی",
|
||||
slug: SLUG,
|
||||
about:
|
||||
"IranBay marketplace platform identity tenant (auth only — not a Meshkee storefront).",
|
||||
categoryIds: [Number(other.id)],
|
||||
ownerFirstName: "IranBay",
|
||||
ownerLastName: "Platform",
|
||||
ownerFirstNameEn: "IranBay",
|
||||
ownerLastNameEn: "Platform",
|
||||
ownerCellNumber: ownerCell,
|
||||
ownerPassword,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
if (String(err.message).toLowerCase().includes("slug")) {
|
||||
console.log("Business slug may already exist — searching…");
|
||||
const search = await request(
|
||||
`/businesses/search?q=${encodeURIComponent(SLUG)}&limit=5`,
|
||||
{ token },
|
||||
);
|
||||
business = (search.items ?? []).find(
|
||||
(item) => String(item.slug) === SLUG,
|
||||
);
|
||||
if (!business) throw err;
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
const businessId = String(business.id);
|
||||
console.log(`✓ Meshkee business id=${businessId}`);
|
||||
|
||||
console.log(`Attaching domain ${HOST} (no storefront / no DNS)…`);
|
||||
try {
|
||||
await request(`/businesses/${businessId}/domains`, {
|
||||
method: "POST",
|
||||
token,
|
||||
body: {
|
||||
host: HOST,
|
||||
isPrimary: true,
|
||||
updateDns: false,
|
||||
},
|
||||
});
|
||||
console.log(`✓ Domain ${HOST} linked`);
|
||||
} catch (err) {
|
||||
if (String(err.message).toLowerCase().includes("already")) {
|
||||
console.log(`✓ Domain already attached (${err.message})`);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
if (!(await domainExists())) {
|
||||
throw new Error(
|
||||
`Domain attached but tenants/${HOST} still 404 — check Meshkee domain isActive.`,
|
||||
);
|
||||
}
|
||||
|
||||
console.log("");
|
||||
console.log("Done. Set in dashboard/website:");
|
||||
console.log(` NEXT_PUBLIC_MESHKEE_TENANT_DOMAIN=${HOST}`);
|
||||
console.log("");
|
||||
console.log(
|
||||
"Remember: producer shops register as SEPARATE Meshkee businesses + IranBay marketplace listings.",
|
||||
);
|
||||
}
|
||||
|
||||
main().catch((err) => {
|
||||
console.error("Bootstrap failed:", err.message);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { PrismaModule } from './prisma/prisma.module';
|
||||
import { BusinessesModule } from './businesses/businesses.module';
|
||||
import { WebsiteModule } from './website/website.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({ isGlobal: true }),
|
||||
PrismaModule,
|
||||
BusinessesModule,
|
||||
WebsiteModule,
|
||||
],
|
||||
})
|
||||
export class AppModule {}
|
||||
@@ -0,0 +1,73 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Headers,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { BusinessesService } from './businesses.service';
|
||||
import {
|
||||
CreateBusinessDto,
|
||||
ListBusinessesDto,
|
||||
RegisterShopDto,
|
||||
UpdateBusinessDto,
|
||||
} from './dto/business.dto';
|
||||
|
||||
@Controller('businesses')
|
||||
export class BusinessesController {
|
||||
constructor(private readonly service: BusinessesService) {}
|
||||
|
||||
@Get()
|
||||
list(@Query() query: ListBusinessesDto) {
|
||||
return this.service.list(query);
|
||||
}
|
||||
|
||||
@Get('by-id/:id')
|
||||
getById(@Param('id') id: string) {
|
||||
return this.service.getById(id);
|
||||
}
|
||||
|
||||
/** Self-serve: create Meshkee shop + IranBay listing (not the iranbay.com auth tenant). */
|
||||
@Post('register-shop')
|
||||
registerShop(
|
||||
@Body() dto: RegisterShopDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
if (!authorization?.trim()) {
|
||||
throw new UnauthorizedException('Sign in required');
|
||||
}
|
||||
return this.service.registerShop(dto, authorization);
|
||||
}
|
||||
|
||||
@Post()
|
||||
create(
|
||||
@Body() dto: CreateBusinessDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
return this.service.create(dto, authorization);
|
||||
}
|
||||
|
||||
@Patch(':id')
|
||||
update(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateBusinessDto,
|
||||
@Headers('authorization') authorization?: string,
|
||||
) {
|
||||
return this.service.update(id, dto, authorization);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.service.remove(id);
|
||||
}
|
||||
|
||||
@Get(':slug')
|
||||
getBySlug(@Param('slug') slug: string) {
|
||||
return this.service.getBySlug(slug);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshkeeModule } from '../meshkee/meshkee.module';
|
||||
import { BusinessesController } from './businesses.controller';
|
||||
import { BusinessesService } from './businesses.service';
|
||||
|
||||
@Module({
|
||||
imports: [MeshkeeModule],
|
||||
controllers: [BusinessesController],
|
||||
providers: [BusinessesService],
|
||||
exports: [BusinessesService],
|
||||
})
|
||||
export class BusinessesModule {}
|
||||
@@ -0,0 +1,285 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { MeshkeeClient } from '../meshkee/meshkee.client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
CreateBusinessDto,
|
||||
ListBusinessesDto,
|
||||
RegisterShopDto,
|
||||
UpdateBusinessDto,
|
||||
} from './dto/business.dto';
|
||||
|
||||
function slugify(value: string) {
|
||||
return value
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '')
|
||||
.slice(0, 80);
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BusinessesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly meshkee: MeshkeeClient,
|
||||
) {}
|
||||
|
||||
async list(query: ListBusinessesDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(query.limit ?? 20, 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.BusinessWhereInput = {};
|
||||
|
||||
if (query.status && query.status !== 'all') {
|
||||
where.status = query.status;
|
||||
} else if (!query.status) {
|
||||
where.status = 'published';
|
||||
}
|
||||
|
||||
if (query.featured === true) {
|
||||
where.isFeatured = true;
|
||||
}
|
||||
|
||||
if (query.q) {
|
||||
where.slug = { contains: query.q, mode: 'insensitive' };
|
||||
}
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.business.findMany({
|
||||
where,
|
||||
orderBy: [
|
||||
{ isFeatured: 'desc' },
|
||||
{ sortWeight: 'desc' },
|
||||
{ publishedAt: 'desc' },
|
||||
{ id: 'desc' },
|
||||
],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
this.prisma.business.count({ where }),
|
||||
]);
|
||||
|
||||
return { items, page, limit, total };
|
||||
}
|
||||
|
||||
async getBySlug(slug: string) {
|
||||
const business = await this.prisma.business.findUnique({ where: { slug } });
|
||||
if (!business) {
|
||||
throw new NotFoundException(`Business "${slug}" not found`);
|
||||
}
|
||||
return business;
|
||||
}
|
||||
|
||||
async getById(id: string) {
|
||||
const business = await this.prisma.business.findUnique({
|
||||
where: { id: BigInt(id) },
|
||||
});
|
||||
if (!business) {
|
||||
throw new NotFoundException(`Business ${id} not found`);
|
||||
}
|
||||
return business;
|
||||
}
|
||||
|
||||
/**
|
||||
* Self-serve shop signup:
|
||||
* 1) Meshkee business owned by the logged-in IranBay user (NOT the iranbay.com tenant)
|
||||
* 2) IranBay marketplace listing (pending_review)
|
||||
*/
|
||||
async registerShop(dto: RegisterShopDto, authorization?: string) {
|
||||
if (!authorization?.trim()) {
|
||||
throw new UnauthorizedException('Sign in required');
|
||||
}
|
||||
|
||||
const user = await this.meshkee.getMe(authorization);
|
||||
const name = dto.name.trim();
|
||||
if (name.length < 2) {
|
||||
throw new BadRequestException('Business name is required');
|
||||
}
|
||||
|
||||
const slug = (dto.slug?.trim() || slugify(name)).slice(0, 100);
|
||||
if (!slug) {
|
||||
throw new BadRequestException('Could not build a slug from the name');
|
||||
}
|
||||
|
||||
let categoryIds = dto.meshkeeCategoryIds?.length
|
||||
? this.parseMeshkeeCategoryIds(dto.meshkeeCategoryIds)
|
||||
: [];
|
||||
|
||||
if (!categoryIds.length) {
|
||||
const categories = await this.meshkee.listBusinessCategories();
|
||||
const other =
|
||||
categories.find((item) => String(item.slug) === 'other') ??
|
||||
categories[0];
|
||||
if (!other) {
|
||||
throw new BadRequestException('No Meshkee categories available');
|
||||
}
|
||||
categoryIds = [Number(other.id)];
|
||||
}
|
||||
|
||||
const firstName =
|
||||
user.firstNameEn?.trim() || user.firstName?.trim() || 'Owner';
|
||||
const lastName =
|
||||
user.lastNameEn?.trim() || user.lastName?.trim() || firstName;
|
||||
|
||||
const created = await this.meshkee.createBusiness({
|
||||
name,
|
||||
nameFa: name,
|
||||
slug,
|
||||
about: dto.about?.trim() || undefined,
|
||||
categoryIds,
|
||||
ownerFirstName: firstName,
|
||||
ownerLastName: lastName.length >= 2 ? lastName : firstName,
|
||||
ownerFirstNameEn: firstName,
|
||||
ownerLastNameEn: lastName.length >= 2 ? lastName : firstName,
|
||||
ownerCellNumber: user.cellNumber,
|
||||
ownerPassword: dto.ownerPassword,
|
||||
});
|
||||
|
||||
try {
|
||||
const listing = await this.prisma.business.create({
|
||||
data: {
|
||||
meshkeeBusinessId: BigInt(created.id),
|
||||
slug: created.slug || slug,
|
||||
status: 'pending_review',
|
||||
isFeatured: false,
|
||||
sortWeight: 0,
|
||||
publishedAt: null,
|
||||
},
|
||||
});
|
||||
return {
|
||||
listing,
|
||||
meshkeeBusinessId: created.id,
|
||||
message:
|
||||
'Shop created in Meshkee and submitted to IranBay for review.',
|
||||
};
|
||||
} catch (error) {
|
||||
this.rethrowUnique(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async create(dto: CreateBusinessDto, authorization?: string) {
|
||||
const status = dto.status ?? 'draft';
|
||||
const now = new Date();
|
||||
|
||||
const meshkeePatch: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
categoryIds?: number[];
|
||||
} = { slug: dto.slug };
|
||||
|
||||
if (dto.name?.trim()) meshkeePatch.name = dto.name.trim();
|
||||
if (dto.meshkeeCategoryIds?.length) {
|
||||
meshkeePatch.categoryIds = this.parseMeshkeeCategoryIds(
|
||||
dto.meshkeeCategoryIds,
|
||||
);
|
||||
}
|
||||
|
||||
await this.meshkee.updateBusiness(
|
||||
dto.meshkeeBusinessId,
|
||||
meshkeePatch,
|
||||
authorization,
|
||||
);
|
||||
|
||||
try {
|
||||
return await this.prisma.business.create({
|
||||
data: {
|
||||
meshkeeBusinessId: BigInt(dto.meshkeeBusinessId),
|
||||
slug: dto.slug,
|
||||
status,
|
||||
isFeatured: dto.isFeatured ?? false,
|
||||
sortWeight: dto.sortWeight ?? 0,
|
||||
publishedAt: status === 'published' ? now : null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowUnique(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async update(id: string, dto: UpdateBusinessDto, authorization?: string) {
|
||||
const existing = await this.getById(id);
|
||||
|
||||
const data: Prisma.BusinessUpdateInput = {
|
||||
slug: dto.slug,
|
||||
status: dto.status,
|
||||
isFeatured: dto.isFeatured,
|
||||
sortWeight: dto.sortWeight,
|
||||
};
|
||||
|
||||
if (dto.status === 'published' && existing.status !== 'published') {
|
||||
data.publishedAt = new Date();
|
||||
}
|
||||
|
||||
const meshkeePatch: {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
categoryIds?: number[];
|
||||
} = {};
|
||||
if (dto.name !== undefined) meshkeePatch.name = dto.name;
|
||||
if (dto.slug !== undefined) meshkeePatch.slug = dto.slug;
|
||||
if (dto.meshkeeCategoryIds !== undefined) {
|
||||
meshkeePatch.categoryIds = this.parseMeshkeeCategoryIds(
|
||||
dto.meshkeeCategoryIds,
|
||||
);
|
||||
}
|
||||
|
||||
if (Object.keys(meshkeePatch).length) {
|
||||
await this.meshkee.updateBusiness(
|
||||
String(existing.meshkeeBusinessId),
|
||||
meshkeePatch,
|
||||
authorization,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.business.update({
|
||||
where: { id: BigInt(id) },
|
||||
data,
|
||||
});
|
||||
} catch (error) {
|
||||
this.rethrowUnique(error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async remove(id: string) {
|
||||
await this.getById(id);
|
||||
await this.prisma.business.delete({ where: { id: BigInt(id) } });
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private parseMeshkeeCategoryIds(ids: string[]): number[] {
|
||||
const parsed = [...new Set(ids)].map((id) => {
|
||||
const n = Number(id);
|
||||
if (!Number.isInteger(n) || n <= 0) {
|
||||
throw new BadRequestException(`Invalid Meshkee category id: ${id}`);
|
||||
}
|
||||
return n;
|
||||
});
|
||||
if (!parsed.length) {
|
||||
throw new BadRequestException('meshkeeCategoryIds cannot be empty');
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
private rethrowUnique(error: unknown): void {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
const target = Array.isArray(error.meta?.target)
|
||||
? error.meta.target.join(', ')
|
||||
: 'field';
|
||||
throw new BadRequestException(`Business ${target} already exists`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import {
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsIn,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
Matches,
|
||||
MaxLength,
|
||||
Min,
|
||||
MinLength,
|
||||
} from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
|
||||
export const BUSINESS_STATUSES = [
|
||||
'draft',
|
||||
'pending_review',
|
||||
'published',
|
||||
'hidden',
|
||||
'rejected',
|
||||
] as const;
|
||||
|
||||
export type BusinessStatus = (typeof BUSINESS_STATUSES)[number];
|
||||
|
||||
export class CreateBusinessDto {
|
||||
@IsString()
|
||||
meshkeeBusinessId!: string;
|
||||
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
@MaxLength(100)
|
||||
slug!: string;
|
||||
|
||||
/** Written to Meshkee only (not stored in marketplace DB). */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
/** Meshkee business_category ids — written to Meshkee only. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
meshkeeCategoryIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BUSINESS_STATUSES)
|
||||
status?: BusinessStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isFeatured?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
sortWeight?: number;
|
||||
}
|
||||
|
||||
/** Public shop signup: creates a NEW Meshkee business + IranBay marketplace listing. */
|
||||
export class RegisterShopDto {
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
@MaxLength(100)
|
||||
slug?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
about?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
meshkeeCategoryIds?: string[];
|
||||
|
||||
/**
|
||||
* Account password confirmation — Meshkee create-owner updates password for
|
||||
* existing cells; must match the registrant’s current password.
|
||||
*/
|
||||
@IsString()
|
||||
@MinLength(8)
|
||||
ownerPassword!: string;
|
||||
}
|
||||
|
||||
export class UpdateBusinessDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Matches(/^[a-z0-9]+(?:-[a-z0-9]+)*$/)
|
||||
@MaxLength(100)
|
||||
slug?: string;
|
||||
|
||||
/** Written to Meshkee only. */
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(255)
|
||||
name?: string;
|
||||
|
||||
/** Meshkee business_category ids — written to Meshkee only. */
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
meshkeeCategoryIds?: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsIn(BUSINESS_STATUSES)
|
||||
status?: BusinessStatus;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isFeatured?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
sortWeight?: number;
|
||||
}
|
||||
|
||||
export class ListBusinessesDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
q?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsIn([...BUSINESS_STATUSES, 'all'])
|
||||
status?: BusinessStatus | 'all';
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
@Type(() => Boolean)
|
||||
featured?: boolean;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number;
|
||||
|
||||
@IsOptional()
|
||||
@Type(() => Number)
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number;
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import {
|
||||
CallHandler,
|
||||
ExecutionContext,
|
||||
Injectable,
|
||||
NestInterceptor,
|
||||
} from '@nestjs/common';
|
||||
import { Observable, map } from 'rxjs';
|
||||
|
||||
function serialize(value: unknown): unknown {
|
||||
if (typeof value === 'bigint') {
|
||||
return value.toString();
|
||||
}
|
||||
if (value instanceof Date) {
|
||||
return value.toISOString();
|
||||
}
|
||||
if (Array.isArray(value)) {
|
||||
return value.map(serialize);
|
||||
}
|
||||
if (value && typeof value === 'object') {
|
||||
// Skip streams / class instances Nest may pass through @Res handlers.
|
||||
const proto = Object.getPrototypeOf(value);
|
||||
if (proto !== Object.prototype && proto !== null) {
|
||||
return value;
|
||||
}
|
||||
const out: Record<string, unknown> = {};
|
||||
for (const [key, entry] of Object.entries(value)) {
|
||||
out[key] = serialize(entry);
|
||||
}
|
||||
return out;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class BigIntSerializerInterceptor implements NestInterceptor {
|
||||
intercept(_context: ExecutionContext, next: CallHandler): Observable<unknown> {
|
||||
return next.handle().pipe(map((data) => serialize(data)));
|
||||
}
|
||||
}
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
import { NestFactory } from '@nestjs/core';
|
||||
import { ValidationPipe } from '@nestjs/common';
|
||||
import { AppModule } from './app.module';
|
||||
import { BigIntSerializerInterceptor } from './common/interceptors/bigint-serializer.interceptor';
|
||||
|
||||
async function bootstrap() {
|
||||
const app = await NestFactory.create(AppModule);
|
||||
|
||||
app.setGlobalPrefix('api/v1');
|
||||
app.useGlobalPipes(
|
||||
new ValidationPipe({
|
||||
whitelist: true,
|
||||
forbidNonWhitelisted: true,
|
||||
transform: true,
|
||||
}),
|
||||
);
|
||||
app.useGlobalInterceptors(new BigIntSerializerInterceptor());
|
||||
app.enableCors();
|
||||
|
||||
const port = Number(process.env.PORT ?? 3001);
|
||||
await app.listen(port);
|
||||
console.log(`Marketplace API listening on http://localhost:${port}/api/v1`);
|
||||
}
|
||||
|
||||
bootstrap();
|
||||
@@ -0,0 +1,449 @@
|
||||
import {
|
||||
BadGatewayException,
|
||||
Injectable,
|
||||
Logger,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
|
||||
export type MeshkeeBusinessUpdate = {
|
||||
name?: string;
|
||||
slug?: string;
|
||||
nameFa?: string;
|
||||
about?: string;
|
||||
categoryIds?: number[];
|
||||
};
|
||||
|
||||
export type MeshkeeCreateBusinessInput = {
|
||||
name: string;
|
||||
nameFa: string;
|
||||
slug?: string;
|
||||
about?: string;
|
||||
categoryIds: number[];
|
||||
ownerFirstName: string;
|
||||
ownerLastName: string;
|
||||
ownerFirstNameEn: string;
|
||||
ownerLastNameEn: string;
|
||||
ownerCellNumber: string;
|
||||
ownerPassword: string;
|
||||
};
|
||||
|
||||
export type MeshkeeAuthUser = {
|
||||
id: string;
|
||||
cellNumber: string;
|
||||
email: string | null;
|
||||
firstName: string | null;
|
||||
lastName: string | null;
|
||||
firstNameEn: string | null;
|
||||
lastNameEn: string | null;
|
||||
dashboard: string;
|
||||
};
|
||||
|
||||
type CachedToken = { token: string; expiresAt: number };
|
||||
|
||||
@Injectable()
|
||||
export class MeshkeeClient {
|
||||
private readonly logger = new Logger(MeshkeeClient.name);
|
||||
private cachedToken: CachedToken | null = null;
|
||||
|
||||
constructor(private readonly config: ConfigService) {}
|
||||
|
||||
private apiBase(): string {
|
||||
return (
|
||||
this.config.get<string>('MESHKEE_API_URL')?.replace(/\/$/, '') ||
|
||||
'http://localhost:3010/api/v1'
|
||||
);
|
||||
}
|
||||
|
||||
private tokenExpiry(token: string) {
|
||||
try {
|
||||
const payload = JSON.parse(
|
||||
Buffer.from(token.split('.')[1] ?? '', 'base64url').toString(),
|
||||
) as { exp?: number };
|
||||
if (typeof payload.exp === 'number') return payload.exp * 1000;
|
||||
} catch {
|
||||
/* ignore malformed tokens */
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
private usableConfiguredToken() {
|
||||
const token = this.config.get<string>('MESHKEE_API_TOKEN')?.trim();
|
||||
if (!token) return null;
|
||||
const expiresAt = this.tokenExpiry(token);
|
||||
if (expiresAt && expiresAt < Date.now() + 15_000) return null;
|
||||
return token;
|
||||
}
|
||||
|
||||
private async loginServiceToken(): Promise<string | null> {
|
||||
const cellNumber = this.config.get<string>('MESHKEE_SUPERADMIN_CELL')?.trim();
|
||||
const password = this.config
|
||||
.get<string>('MESHKEE_SUPERADMIN_PASSWORD')
|
||||
?.trim();
|
||||
if (!cellNumber || !password) return null;
|
||||
|
||||
const response = await fetch(`${this.apiBase()}/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ cellNumber, password }),
|
||||
});
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Meshkee service login failed (${response.status})`,
|
||||
);
|
||||
return null;
|
||||
}
|
||||
|
||||
const payload = (await response.json().catch(() => null)) as {
|
||||
accessToken?: string;
|
||||
} | null;
|
||||
const token = payload?.accessToken?.trim();
|
||||
if (!token) return null;
|
||||
|
||||
this.cachedToken = {
|
||||
token,
|
||||
expiresAt: this.tokenExpiry(token) || Date.now() + 10 * 60 * 1000,
|
||||
};
|
||||
return token;
|
||||
}
|
||||
|
||||
private async resolveServiceToken(): Promise<string | null> {
|
||||
const now = Date.now();
|
||||
if (this.cachedToken && this.cachedToken.expiresAt > now + 15_000) {
|
||||
return this.cachedToken.token;
|
||||
}
|
||||
|
||||
const configured = this.usableConfiguredToken();
|
||||
if (configured) return configured;
|
||||
|
||||
return this.loginServiceToken();
|
||||
}
|
||||
|
||||
private async authHeader(authorization?: string): Promise<string | null> {
|
||||
if (authorization?.trim()) return authorization.trim();
|
||||
const token = await this.resolveServiceToken();
|
||||
return token ? `Bearer ${token}` : null;
|
||||
}
|
||||
|
||||
private async serviceAuth(): Promise<string> {
|
||||
const auth = await this.authHeader();
|
||||
if (!auth) {
|
||||
throw new UnauthorizedException(
|
||||
'Meshkee auth missing. Set MESHKEE_API_TOKEN or MESHKEE_SUPERADMIN_CELL + MESHKEE_SUPERADMIN_PASSWORD.',
|
||||
);
|
||||
}
|
||||
return auth;
|
||||
}
|
||||
|
||||
private async parseError(response: Response, fallback: string) {
|
||||
const text = await response.text().catch(() => '');
|
||||
try {
|
||||
const json = JSON.parse(text) as { message?: string | string[] };
|
||||
if (Array.isArray(json.message)) return json.message.join(', ');
|
||||
if (typeof json.message === 'string') return json.message;
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return text.slice(0, 300) || fallback;
|
||||
}
|
||||
|
||||
async getMe(authorization: string): Promise<MeshkeeAuthUser> {
|
||||
const response = await fetch(`${this.apiBase()}/auth/me`, {
|
||||
headers: { Authorization: authorization },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new UnauthorizedException('Invalid or expired Meshkee session');
|
||||
}
|
||||
const payload = (await response.json()) as { user: MeshkeeAuthUser };
|
||||
return payload.user;
|
||||
}
|
||||
|
||||
async listBusinessCategories(authorization?: string) {
|
||||
const auth = (await this.authHeader(authorization)) ?? (await this.serviceAuth());
|
||||
const response = await fetch(`${this.apiBase()}/business-categories`, {
|
||||
headers: { Authorization: auth },
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new BadGatewayException(
|
||||
await this.parseError(response, 'Failed to list Meshkee categories'),
|
||||
);
|
||||
}
|
||||
const payload = (await response.json()) as {
|
||||
items?: Array<{
|
||||
id: string | number;
|
||||
parentId?: string | number | null;
|
||||
parent_id?: string | number | null;
|
||||
name?: string;
|
||||
nameFa?: string | null;
|
||||
name_fa?: string | null;
|
||||
slug?: string;
|
||||
}>;
|
||||
};
|
||||
return (payload.items ?? []).map((item) => ({
|
||||
id: String(item.id),
|
||||
parentId:
|
||||
item.parentId == null && item.parent_id == null
|
||||
? null
|
||||
: String(item.parentId ?? item.parent_id),
|
||||
name: String(item.name ?? ''),
|
||||
nameFa:
|
||||
item.nameFa == null && item.name_fa == null
|
||||
? null
|
||||
: String(item.nameFa ?? item.name_fa),
|
||||
slug: String(item.slug ?? ''),
|
||||
}));
|
||||
}
|
||||
|
||||
async createBusiness(
|
||||
body: MeshkeeCreateBusinessInput,
|
||||
authorization?: string,
|
||||
) {
|
||||
const auth = (await this.authHeader(authorization)) ?? (await this.serviceAuth());
|
||||
const response = await fetch(`${this.apiBase()}/businesses`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
throw new BadGatewayException(
|
||||
await this.parseError(response, 'Failed to create Meshkee business'),
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
id: string | number;
|
||||
slug?: string;
|
||||
name?: string;
|
||||
};
|
||||
return { ...data, id: String(data.id) };
|
||||
}
|
||||
|
||||
async addDomain(businessId: string, host: string, authorization?: string) {
|
||||
const auth = (await this.authHeader(authorization)) ?? (await this.serviceAuth());
|
||||
const response = await fetch(
|
||||
`${this.apiBase()}/businesses/${businessId}/domains`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify({
|
||||
host,
|
||||
isPrimary: true,
|
||||
updateDns: false,
|
||||
}),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new BadGatewayException(
|
||||
await this.parseError(response, 'Failed to attach Meshkee domain'),
|
||||
);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
async getBusiness(
|
||||
meshkeeBusinessId: string,
|
||||
authorization?: string,
|
||||
): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
nameFa: string | null;
|
||||
slug: string;
|
||||
logoUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
categories: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
parentId: string | null;
|
||||
}>;
|
||||
}> {
|
||||
const auth =
|
||||
(await this.authHeader(authorization)) ?? (await this.serviceAuth());
|
||||
const response = await fetch(
|
||||
`${this.apiBase()}/businesses/${meshkeeBusinessId}`,
|
||||
{ headers: { Authorization: auth } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new BadGatewayException(
|
||||
await this.parseError(response, 'Failed to load Meshkee business'),
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
id: string | number;
|
||||
name?: string;
|
||||
nameFa?: string | null;
|
||||
slug?: string;
|
||||
logoUrl?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
categories?: Array<{
|
||||
id: string | number;
|
||||
name?: string;
|
||||
slug?: string;
|
||||
parentId?: string | number | null;
|
||||
parent_id?: string | number | null;
|
||||
}>;
|
||||
};
|
||||
return {
|
||||
id: String(data.id),
|
||||
name: data.name ?? '',
|
||||
nameFa: data.nameFa ?? null,
|
||||
slug: data.slug ?? '',
|
||||
logoUrl: data.logoUrl ?? null,
|
||||
bannerUrl: data.bannerUrl ?? null,
|
||||
categories: (data.categories ?? []).map((category) => ({
|
||||
id: String(category.id),
|
||||
name: category.name ?? '',
|
||||
slug: category.slug ?? '',
|
||||
parentId:
|
||||
category.parentId == null && category.parent_id == null
|
||||
? null
|
||||
: String(category.parentId ?? category.parent_id),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async getBusinessProfile(
|
||||
meshkeeBusinessId: string,
|
||||
authorization?: string,
|
||||
): Promise<{
|
||||
nameEn: string;
|
||||
nameFa: string;
|
||||
title: string;
|
||||
about: string;
|
||||
logoUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
categoryIds: string[];
|
||||
}> {
|
||||
const auth =
|
||||
(await this.authHeader(authorization)) ?? (await this.serviceAuth());
|
||||
const response = await fetch(
|
||||
`${this.apiBase()}/businesses/${meshkeeBusinessId}/profile`,
|
||||
{ headers: { Authorization: auth } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
throw new BadGatewayException(
|
||||
await this.parseError(
|
||||
response,
|
||||
'Failed to load Meshkee business profile',
|
||||
),
|
||||
);
|
||||
}
|
||||
const data = (await response.json()) as {
|
||||
profile?: {
|
||||
nameEn?: string;
|
||||
nameFa?: string;
|
||||
title?: string;
|
||||
about?: string;
|
||||
logoUrl?: string | null;
|
||||
bannerUrl?: string | null;
|
||||
categoryIds?: Array<string | number>;
|
||||
};
|
||||
};
|
||||
const profile = data.profile ?? {};
|
||||
return {
|
||||
nameEn: profile.nameEn ?? '',
|
||||
nameFa: profile.nameFa ?? '',
|
||||
title: profile.title ?? '',
|
||||
about: profile.about ?? '',
|
||||
logoUrl: profile.logoUrl ?? null,
|
||||
bannerUrl: profile.bannerUrl ?? null,
|
||||
categoryIds: (profile.categoryIds ?? []).map(String),
|
||||
};
|
||||
}
|
||||
|
||||
async getProductCount(
|
||||
meshkeeBusinessId: string,
|
||||
authorization?: string,
|
||||
): Promise<number> {
|
||||
const auth =
|
||||
(await this.authHeader(authorization)) ?? (await this.serviceAuth());
|
||||
const params = new URLSearchParams({ page: '1', pageSize: '1' });
|
||||
const response = await fetch(
|
||||
`${this.apiBase()}/businesses/${meshkeeBusinessId}/products?${params}`,
|
||||
{ headers: { Authorization: auth } },
|
||||
);
|
||||
if (!response.ok) {
|
||||
this.logger.warn(
|
||||
`Failed to load product count for ${meshkeeBusinessId} (${response.status})`,
|
||||
);
|
||||
return 0;
|
||||
}
|
||||
const data = (await response.json().catch(() => null)) as {
|
||||
total?: number;
|
||||
items?: unknown[];
|
||||
} | null;
|
||||
if (typeof data?.total === 'number') return data.total;
|
||||
return Array.isArray(data?.items) ? data.items.length : 0;
|
||||
}
|
||||
|
||||
/** Write profile / category fields to Meshkee (source of truth for content). */
|
||||
async updateBusiness(
|
||||
meshkeeBusinessId: string,
|
||||
body: MeshkeeBusinessUpdate,
|
||||
authorization?: string,
|
||||
): Promise<void> {
|
||||
let auth = await this.authHeader(authorization);
|
||||
if (!auth) {
|
||||
this.logger.warn(
|
||||
`Skipping Meshkee update for business ${meshkeeBusinessId}: no auth token`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const payload: Record<string, unknown> = {};
|
||||
if (body.name !== undefined) payload.name = body.name;
|
||||
if (body.slug !== undefined) payload.slug = body.slug;
|
||||
if (body.nameFa !== undefined) payload.nameFa = body.nameFa;
|
||||
if (body.about !== undefined) payload.about = body.about;
|
||||
if (body.categoryIds !== undefined) payload.categoryIds = body.categoryIds;
|
||||
|
||||
if (Object.keys(payload).length === 0) return;
|
||||
|
||||
let response = await fetch(
|
||||
`${this.apiBase()}/businesses/${meshkeeBusinessId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
|
||||
// Incoming user JWT may be for a different Meshkee host; retry with service login.
|
||||
if (response.status === 401) {
|
||||
this.cachedToken = null;
|
||||
const serviceToken = await this.loginServiceToken();
|
||||
if (serviceToken) {
|
||||
auth = `Bearer ${serviceToken}`;
|
||||
response = await fetch(
|
||||
`${this.apiBase()}/businesses/${meshkeeBusinessId}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
Authorization: auth,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text().catch(() => '');
|
||||
this.logger.error(
|
||||
`Meshkee update failed (${response.status}): ${text.slice(0, 300)}`,
|
||||
);
|
||||
throw new BadGatewayException(
|
||||
`Failed to update Meshkee business (${response.status})`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshkeeClient } from './meshkee.client';
|
||||
|
||||
@Module({
|
||||
providers: [MeshkeeClient],
|
||||
exports: [MeshkeeClient],
|
||||
})
|
||||
export class MeshkeeModule {}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Global, Module } from '@nestjs/common';
|
||||
import { PrismaService } from './prisma.service';
|
||||
|
||||
@Global()
|
||||
@Module({
|
||||
providers: [PrismaService],
|
||||
exports: [PrismaService],
|
||||
})
|
||||
export class PrismaModule {}
|
||||
@@ -0,0 +1,16 @@
|
||||
import { Injectable, OnModuleDestroy, OnModuleInit } from '@nestjs/common';
|
||||
import { PrismaClient } from '@prisma/client';
|
||||
|
||||
@Injectable()
|
||||
export class PrismaService
|
||||
extends PrismaClient
|
||||
implements OnModuleInit, OnModuleDestroy
|
||||
{
|
||||
async onModuleInit() {
|
||||
await this.$connect();
|
||||
}
|
||||
|
||||
async onModuleDestroy() {
|
||||
await this.$disconnect();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class SpecialCarouselDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
title!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtext?: string;
|
||||
|
||||
@IsString()
|
||||
slug!: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
businessIds!: string[];
|
||||
}
|
||||
|
||||
export class UpdateSpecialBusinessesDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SpecialCarouselDto)
|
||||
carousels!: SpecialCarouselDto[];
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
IsArray,
|
||||
IsNotEmpty,
|
||||
IsOptional,
|
||||
IsString,
|
||||
MaxLength,
|
||||
} from 'class-validator';
|
||||
|
||||
export class CreateWebsiteBlogCategoryDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(120)
|
||||
name!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
parentId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(120)
|
||||
nameFa?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export class CreateWebsiteBlogPostDto {
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MaxLength(200)
|
||||
title!: string;
|
||||
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
categoryId!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
abstract?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
mainTextHtml?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(2000)
|
||||
imageUrl?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ArrayMaxSize(30)
|
||||
@IsString({ each: true })
|
||||
@MaxLength(80, { each: true })
|
||||
tags?: string[];
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
import { Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMaxSize,
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsOptional,
|
||||
IsString,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class WebsiteMediaItemDto {
|
||||
@IsString()
|
||||
id!: string;
|
||||
|
||||
@IsString()
|
||||
imageUrl!: string;
|
||||
|
||||
@IsString()
|
||||
linkUrl!: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
title?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
subtitle?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
cta?: string;
|
||||
}
|
||||
|
||||
export class UpdateWebsiteHomeMediaDto {
|
||||
@IsArray()
|
||||
@ArrayMinSize(1)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteMediaItemDto)
|
||||
slider!: WebsiteMediaItemDto[];
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(2)
|
||||
@ArrayMaxSize(2)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteMediaItemDto)
|
||||
standingBanners!: WebsiteMediaItemDto[];
|
||||
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteMediaItemDto)
|
||||
categories!: WebsiteMediaItemDto[];
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(4)
|
||||
@ArrayMaxSize(4)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteMediaItemDto)
|
||||
menuImages!: WebsiteMediaItemDto[];
|
||||
|
||||
@ValidateNested()
|
||||
@Type(() => WebsiteMediaItemDto)
|
||||
aboutUs!: WebsiteMediaItemDto;
|
||||
|
||||
@IsArray()
|
||||
@ArrayMinSize(5)
|
||||
@ArrayMaxSize(5)
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => WebsiteMediaItemDto)
|
||||
banners!: WebsiteMediaItemDto[];
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
} from '@nestjs/common';
|
||||
import {
|
||||
CreateWebsiteBlogCategoryDto,
|
||||
CreateWebsiteBlogPostDto,
|
||||
} from './dto/website-blog.dto';
|
||||
import { WebsiteBlogService } from './website-blog.service';
|
||||
|
||||
@Controller('website/blog')
|
||||
export class WebsiteBlogController {
|
||||
constructor(private readonly blog: WebsiteBlogService) {}
|
||||
|
||||
@Get('categories')
|
||||
listCategories() {
|
||||
return this.blog.listCategories();
|
||||
}
|
||||
|
||||
@Post('categories')
|
||||
createCategory(@Body() body: CreateWebsiteBlogCategoryDto) {
|
||||
return this.blog.createCategory(body);
|
||||
}
|
||||
|
||||
@Patch('categories/:id')
|
||||
updateCategory(
|
||||
@Param('id') id: string,
|
||||
@Body() body: CreateWebsiteBlogCategoryDto,
|
||||
) {
|
||||
return this.blog.updateCategory(id, body);
|
||||
}
|
||||
|
||||
@Delete('categories/:id')
|
||||
deleteCategory(@Param('id') id: string) {
|
||||
return this.blog.deleteCategory(id);
|
||||
}
|
||||
|
||||
@Get('posts')
|
||||
listPosts() {
|
||||
return this.blog.listPosts();
|
||||
}
|
||||
|
||||
@Post('posts')
|
||||
createPost(@Body() body: CreateWebsiteBlogPostDto) {
|
||||
return this.blog.createPost(body);
|
||||
}
|
||||
|
||||
@Delete('posts/:id')
|
||||
deletePost(@Param('id') id: string) {
|
||||
return this.blog.deletePost(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,296 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import type {
|
||||
CreateWebsiteBlogCategoryDto,
|
||||
CreateWebsiteBlogPostDto,
|
||||
} from './dto/website-blog.dto';
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteBlogService {
|
||||
constructor(private readonly prisma: PrismaService) {}
|
||||
|
||||
|
||||
async listCategories() {
|
||||
const items = await this.prisma.websiteBlogCategory.findMany({
|
||||
orderBy: { name: 'asc' },
|
||||
});
|
||||
return { items };
|
||||
}
|
||||
|
||||
async createCategory(dto: CreateWebsiteBlogCategoryDto) {
|
||||
const name = dto.name.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Enter a category name.');
|
||||
}
|
||||
|
||||
const parentId = dto.parentId?.trim()
|
||||
? this.parseId(dto.parentId.trim())
|
||||
: null;
|
||||
if (parentId) {
|
||||
const parent = await this.prisma.websiteBlogCategory.findUnique({
|
||||
where: { id: parentId },
|
||||
});
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent category not found.');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.prisma.websiteBlogCategory.findFirst({
|
||||
where: {
|
||||
parentId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('This category already exists here.');
|
||||
}
|
||||
|
||||
try {
|
||||
return await this.prisma.websiteBlogCategory.create({
|
||||
data: {
|
||||
name,
|
||||
nameFa: dto.nameFa?.trim() || null,
|
||||
description: dto.description?.trim() || null,
|
||||
parentId: parentId ?? null,
|
||||
},
|
||||
});
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2002'
|
||||
) {
|
||||
throw new BadRequestException('This category already exists here.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
async updateCategory(id: string, dto: CreateWebsiteBlogCategoryDto) {
|
||||
const categoryId = this.parseId(id);
|
||||
const current = await this.prisma.websiteBlogCategory.findUnique({
|
||||
where: { id: categoryId },
|
||||
});
|
||||
if (!current) {
|
||||
throw new NotFoundException('Category not found.');
|
||||
}
|
||||
|
||||
const name = dto.name.trim();
|
||||
if (!name) {
|
||||
throw new BadRequestException('Enter a category name.');
|
||||
}
|
||||
|
||||
const parentId = dto.parentId?.trim()
|
||||
? this.parseId(dto.parentId.trim())
|
||||
: null;
|
||||
if (parentId === categoryId) {
|
||||
throw new BadRequestException('A category cannot be its own parent.');
|
||||
}
|
||||
|
||||
const rows = await this.prisma.websiteBlogCategory.findMany({
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
if (parentId) {
|
||||
const descendants = this.descendantIds(categoryId, rows);
|
||||
if (descendants.has(parentId.toString())) {
|
||||
throw new BadRequestException(
|
||||
'Choose a parent outside this category.',
|
||||
);
|
||||
}
|
||||
const parent = rows.find((row) => row.id === parentId);
|
||||
if (!parent) {
|
||||
throw new NotFoundException('Parent category not found.');
|
||||
}
|
||||
}
|
||||
|
||||
const existing = await this.prisma.websiteBlogCategory.findFirst({
|
||||
where: {
|
||||
parentId,
|
||||
name: { equals: name, mode: 'insensitive' },
|
||||
NOT: { id: categoryId },
|
||||
},
|
||||
});
|
||||
if (existing) {
|
||||
throw new BadRequestException('This category already exists here.');
|
||||
}
|
||||
|
||||
return this.prisma.websiteBlogCategory.update({
|
||||
where: { id: categoryId },
|
||||
data: {
|
||||
name,
|
||||
nameFa: dto.nameFa?.trim() || null,
|
||||
description: dto.description?.trim() || null,
|
||||
parentId,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async deleteCategory(id: string) {
|
||||
const rootId = this.parseId(id);
|
||||
const rows = await this.prisma.websiteBlogCategory.findMany({
|
||||
select: { id: true, parentId: true },
|
||||
});
|
||||
if (!rows.some((row) => row.id === rootId)) {
|
||||
throw new NotFoundException('Category not found.');
|
||||
}
|
||||
|
||||
const subtree = this.subtreePostOrder(rootId, rows);
|
||||
const posts = await this.prisma.websiteBlogPost.count({
|
||||
where: { categoryId: { in: subtree } },
|
||||
});
|
||||
if (posts > 0) {
|
||||
throw new BadRequestException(
|
||||
'Remove posts in this category before deleting it.',
|
||||
);
|
||||
}
|
||||
|
||||
for (const categoryId of subtree) {
|
||||
await this.prisma.websiteBlogCategory.delete({ where: { id: categoryId } });
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async listPosts() {
|
||||
const posts = await this.prisma.websiteBlogPost.findMany({
|
||||
orderBy: { createdAt: 'desc' },
|
||||
include: { category: true },
|
||||
});
|
||||
return {
|
||||
total: posts.length,
|
||||
items: posts.map((post) => ({
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
abstract: post.abstract,
|
||||
imageUrl: post.imageUrl,
|
||||
categoryId: post.categoryId,
|
||||
categoryName: post.category.name,
|
||||
tags: post.tags,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async createPost(dto: CreateWebsiteBlogPostDto) {
|
||||
const categoryId = this.parseId(dto.categoryId);
|
||||
const category = await this.prisma.websiteBlogCategory.findUnique({
|
||||
where: { id: categoryId },
|
||||
});
|
||||
if (!category) {
|
||||
throw new NotFoundException('Category not found.');
|
||||
}
|
||||
|
||||
const post = await this.prisma.websiteBlogPost.create({
|
||||
data: {
|
||||
title: dto.title.trim(),
|
||||
abstract: dto.abstract?.trim() || null,
|
||||
mainTextHtml: dto.mainTextHtml?.trim() || null,
|
||||
imageUrl: dto.imageUrl?.trim() || null,
|
||||
categoryId,
|
||||
tags: this.normalizeTags(dto.tags),
|
||||
},
|
||||
include: { category: true },
|
||||
});
|
||||
|
||||
return {
|
||||
id: post.id,
|
||||
title: post.title,
|
||||
abstract: post.abstract,
|
||||
mainTextHtml: post.mainTextHtml,
|
||||
imageUrl: post.imageUrl,
|
||||
categoryId: post.categoryId,
|
||||
categoryName: post.category.name,
|
||||
tags: post.tags,
|
||||
createdAt: post.createdAt,
|
||||
updatedAt: post.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
async deletePost(id: string) {
|
||||
const postId = this.parseId(id);
|
||||
try {
|
||||
await this.prisma.websiteBlogPost.delete({ where: { id: postId } });
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === 'P2025'
|
||||
) {
|
||||
throw new NotFoundException('Blog post not found.');
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private descendantIds(
|
||||
rootId: bigint,
|
||||
rows: { id: bigint; parentId: bigint | null }[],
|
||||
) {
|
||||
const childrenByParent = new Map<string, bigint[]>();
|
||||
for (const row of rows) {
|
||||
if (row.parentId == null) continue;
|
||||
const key = row.parentId.toString();
|
||||
const list = childrenByParent.get(key) ?? [];
|
||||
list.push(row.id);
|
||||
childrenByParent.set(key, list);
|
||||
}
|
||||
const result = new Set<string>();
|
||||
const queue = [...(childrenByParent.get(rootId.toString()) ?? [])];
|
||||
while (queue.length) {
|
||||
const current = queue.shift()!;
|
||||
const key = current.toString();
|
||||
if (result.has(key)) continue;
|
||||
result.add(key);
|
||||
queue.push(...(childrenByParent.get(key) ?? []));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
private subtreePostOrder(
|
||||
rootId: bigint,
|
||||
rows: { id: bigint; parentId: bigint | null }[],
|
||||
) {
|
||||
const childrenByParent = new Map<string, bigint[]>();
|
||||
for (const row of rows) {
|
||||
if (row.parentId == null) continue;
|
||||
const key = row.parentId.toString();
|
||||
const list = childrenByParent.get(key) ?? [];
|
||||
list.push(row.id);
|
||||
childrenByParent.set(key, list);
|
||||
}
|
||||
const order: bigint[] = [];
|
||||
const walk = (current: bigint) => {
|
||||
for (const child of childrenByParent.get(current.toString()) ?? []) {
|
||||
walk(child);
|
||||
}
|
||||
order.push(current);
|
||||
};
|
||||
walk(rootId);
|
||||
return order;
|
||||
}
|
||||
|
||||
private parseId(value: string) {
|
||||
if (!/^\d+$/.test(value)) {
|
||||
throw new BadRequestException('Invalid id.');
|
||||
}
|
||||
return BigInt(value);
|
||||
}
|
||||
|
||||
private normalizeTags(tags: string[] | undefined) {
|
||||
const seen = new Set<string>();
|
||||
const next: string[] = [];
|
||||
for (const tag of tags ?? []) {
|
||||
const trimmed = tag.trim();
|
||||
if (!trimmed) continue;
|
||||
const key = trimmed.toLowerCase();
|
||||
if (seen.has(key)) continue;
|
||||
seen.add(key);
|
||||
next.push(trimmed);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type SpecialCarousel = {
|
||||
id: string;
|
||||
title: string;
|
||||
/** Short supporting line under the carousel title */
|
||||
subtext: string;
|
||||
/** Website section anchor, e.g. producers */
|
||||
slug: string;
|
||||
/** Marketplace listing ids */
|
||||
businessIds: string[];
|
||||
};
|
||||
|
||||
export type SpecialBusinessesConfig = {
|
||||
carousels: SpecialCarousel[];
|
||||
};
|
||||
|
||||
/** Public website card resolved from marketplace + Meshkee. */
|
||||
export type WebsiteCarouselBusiness = {
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
tagline: string;
|
||||
about: string;
|
||||
logoUrl: string | null;
|
||||
bannerUrl: string | null;
|
||||
categoryName: string | null;
|
||||
productCount: number;
|
||||
publishedAt: string | null;
|
||||
};
|
||||
|
||||
export type WebsiteCarousel = {
|
||||
id: string;
|
||||
title: string;
|
||||
subtext: string;
|
||||
slug: string;
|
||||
businesses: WebsiteCarouselBusiness[];
|
||||
};
|
||||
|
||||
export type WebsiteCarouselsResponse = {
|
||||
carousels: WebsiteCarousel[];
|
||||
};
|
||||
|
||||
export const SPECIAL_BUSINESSES_KEY = 'special_businesses';
|
||||
|
||||
export function defaultSpecialBusinesses(): SpecialBusinessesConfig {
|
||||
return {
|
||||
carousels: [
|
||||
{
|
||||
id: 'carousel-producers',
|
||||
title: 'Featured producers',
|
||||
subtext: '',
|
||||
slug: 'producers',
|
||||
businessIds: [],
|
||||
},
|
||||
{
|
||||
id: 'carousel-stores',
|
||||
title: 'Featured Stores',
|
||||
subtext: '',
|
||||
slug: 'vision',
|
||||
businessIds: [],
|
||||
},
|
||||
{
|
||||
id: 'carousel-services',
|
||||
title: 'Featured Service providers',
|
||||
subtext: '',
|
||||
slug: 'services',
|
||||
businessIds: [],
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
NotFoundException,
|
||||
Param,
|
||||
Put,
|
||||
Post,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import type { Response } from 'express';
|
||||
import { existsSync } from 'fs';
|
||||
import { basename, join } from 'path';
|
||||
import { UpdateSpecialBusinessesDto } from './dto/special-businesses.dto';
|
||||
import { UpdateWebsiteHomeMediaDto } from './dto/website-home-media.dto';
|
||||
import { WebsiteService } from './website.service';
|
||||
|
||||
@Controller('website')
|
||||
export class WebsiteController {
|
||||
constructor(private readonly website: WebsiteService) {}
|
||||
|
||||
@Get('home-media')
|
||||
getHomeMedia() {
|
||||
return this.website.getHomeMedia();
|
||||
}
|
||||
|
||||
@Put('home-media')
|
||||
saveHomeMedia(@Body() body: UpdateWebsiteHomeMediaDto) {
|
||||
return this.website.saveHomeMedia(body);
|
||||
}
|
||||
|
||||
@Get('special-businesses')
|
||||
getSpecialBusinesses() {
|
||||
return this.website.getSpecialBusinesses();
|
||||
}
|
||||
|
||||
/** Public homepage carousels with resolved business cards. */
|
||||
@Get('carousels')
|
||||
getCarousels() {
|
||||
return this.website.getPublicCarousels();
|
||||
}
|
||||
|
||||
@Put('special-businesses')
|
||||
saveSpecialBusinesses(@Body() body: UpdateSpecialBusinessesDto) {
|
||||
return this.website.saveSpecialBusinesses(body);
|
||||
}
|
||||
|
||||
@Post('upload')
|
||||
@UseInterceptors(
|
||||
FileInterceptor('file', {
|
||||
limits: { fileSize: 8 * 1024 * 1024 },
|
||||
}),
|
||||
)
|
||||
upload(@UploadedFile() file: Express.Multer.File) {
|
||||
if (!file) {
|
||||
throw new BadRequestException('No file uploaded');
|
||||
}
|
||||
return this.website.saveUpload(file);
|
||||
}
|
||||
|
||||
@Get('files/:filename')
|
||||
serveFile(@Param('filename') filename: string, @Res() res: Response) {
|
||||
const safe = basename(filename);
|
||||
const path = join(process.cwd(), 'uploads', 'website', safe);
|
||||
if (!existsSync(path)) {
|
||||
throw new NotFoundException('File not found');
|
||||
}
|
||||
// Bypass Nest interceptors — streaming a ReadStream through JSON
|
||||
// serializers overflows the call stack.
|
||||
res.sendFile(path);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,122 @@
|
||||
export type WebsiteMediaItem = {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
linkUrl: string;
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
cta?: string;
|
||||
};
|
||||
|
||||
export type WebsiteHomeMedia = {
|
||||
slider: WebsiteMediaItem[];
|
||||
standingBanners: WebsiteMediaItem[];
|
||||
categories: WebsiteMediaItem[];
|
||||
/** Featured photos for the categories mega menu (one per root, plus a spare). */
|
||||
menuImages: WebsiteMediaItem[];
|
||||
aboutUs: WebsiteMediaItem;
|
||||
banners: WebsiteMediaItem[];
|
||||
};
|
||||
|
||||
export const HOME_MEDIA_KEY = 'home_media';
|
||||
|
||||
function item(
|
||||
id: string,
|
||||
imageUrl: string,
|
||||
linkUrl: string,
|
||||
extra?: Partial<WebsiteMediaItem>,
|
||||
): WebsiteMediaItem {
|
||||
return { id, imageUrl, linkUrl, ...extra };
|
||||
}
|
||||
|
||||
/** Defaults match current static website assets. */
|
||||
export function defaultHomeMedia(): WebsiteHomeMedia {
|
||||
return {
|
||||
slider: [
|
||||
item('slider-1', '/images/hero/furniture.png', '/businesses', {
|
||||
title: 'Iranian modern art',
|
||||
cta: 'browse furniture producers',
|
||||
}),
|
||||
],
|
||||
standingBanners: [
|
||||
item('standing-1', '/images/hero/ice-cream.png', '/businesses', {
|
||||
title: 'food industry',
|
||||
cta: 'browse producers',
|
||||
}),
|
||||
item('standing-2', '/images/hero/rug.png', '/businesses', {
|
||||
title: 'original & pure art',
|
||||
cta: 'browse producers',
|
||||
}),
|
||||
],
|
||||
categories: [
|
||||
item('cat-1', '/images/categories/industry.png', '/businesses', {
|
||||
title: 'industry',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-2', '/images/categories/food.png', '/businesses', {
|
||||
title: 'Food',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-3', '/images/categories/fruits.png', '/businesses', {
|
||||
title: 'Fruits',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-4', '/images/categories/clothes.png', '/businesses', {
|
||||
title: 'Clothes',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-5', '/images/categories/chair.png', '/businesses', {
|
||||
title: 'Home Furniture',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-6', '/images/categories/shoes.png', '/businesses', {
|
||||
title: 'Shoes',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-7', '/images/categories/washer.png', '/businesses', {
|
||||
title: 'Home appliances',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
item('cat-8', '/images/categories/services.png', '/businesses', {
|
||||
title: 'Services',
|
||||
subtitle: 'Some description about the title',
|
||||
}),
|
||||
],
|
||||
menuImages: [
|
||||
item('menu-1', '/images/categories/clothes.png', '/businesses?category=stores', {
|
||||
title: 'Stores',
|
||||
}),
|
||||
item('menu-2', '/images/categories/industry.png', '/businesses?category=industries', {
|
||||
title: 'Industries',
|
||||
}),
|
||||
item('menu-3', '/images/categories/services.png', '/businesses?category=services', {
|
||||
title: 'Services',
|
||||
}),
|
||||
item('menu-4', '/images/categories/shoes.png', '/businesses', {
|
||||
title: 'Menu',
|
||||
}),
|
||||
],
|
||||
aboutUs: item('about-1', '/images/banners/ship.png', '/about'),
|
||||
banners: [
|
||||
item('banner-1', '/images/banners/tv.png', '/businesses', {
|
||||
title: 'Iranian modern art',
|
||||
cta: 'browse furniture producers',
|
||||
}),
|
||||
item('banner-2', '/images/banners/pistachio.png', '/businesses', {
|
||||
title: 'food industry',
|
||||
cta: 'browse producers',
|
||||
}),
|
||||
item('banner-3', '/images/banners/shampoo.png', '/businesses', {
|
||||
title: 'food industry',
|
||||
cta: 'browse producers',
|
||||
}),
|
||||
item('banner-4', '/images/banners/saffron.png', '/businesses', {
|
||||
title: 'original & pure art',
|
||||
cta: 'browse producers',
|
||||
}),
|
||||
item('banner-5', '/images/banners/plane.png', '/register', {
|
||||
title: "let's export",
|
||||
cta: 'begin',
|
||||
}),
|
||||
],
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MeshkeeModule } from '../meshkee/meshkee.module';
|
||||
import { WebsiteBlogController } from './website-blog.controller';
|
||||
import { WebsiteBlogService } from './website-blog.service';
|
||||
import { WebsiteController } from './website.controller';
|
||||
import { WebsiteService } from './website.service';
|
||||
|
||||
@Module({
|
||||
imports: [MeshkeeModule],
|
||||
controllers: [WebsiteController, WebsiteBlogController],
|
||||
providers: [WebsiteService, WebsiteBlogService],
|
||||
})
|
||||
export class WebsiteModule {}
|
||||
@@ -0,0 +1,350 @@
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
import { Prisma } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { mkdir, writeFile } from 'fs/promises';
|
||||
import { extname, join } from 'path';
|
||||
import { MeshkeeClient } from '../meshkee/meshkee.client';
|
||||
import { PrismaService } from '../prisma/prisma.service';
|
||||
import {
|
||||
HOME_MEDIA_KEY,
|
||||
defaultHomeMedia,
|
||||
type WebsiteHomeMedia,
|
||||
type WebsiteMediaItem,
|
||||
} from './website.defaults';
|
||||
import type { UpdateWebsiteHomeMediaDto } from './dto/website-home-media.dto';
|
||||
import {
|
||||
SPECIAL_BUSINESSES_KEY,
|
||||
defaultSpecialBusinesses,
|
||||
type SpecialBusinessesConfig,
|
||||
type SpecialCarousel,
|
||||
type WebsiteCarouselBusiness,
|
||||
type WebsiteCarouselsResponse,
|
||||
} from './website-special.defaults';
|
||||
import type { UpdateSpecialBusinessesDto } from './dto/special-businesses.dto';
|
||||
|
||||
function isMostlyPersian(text: string) {
|
||||
const persian = (text.match(/[\u0600-\u06FF]/g) || []).length;
|
||||
const latin = (text.match(/[A-Za-z0-9]/g) || []).length;
|
||||
return persian > 0 && persian >= latin;
|
||||
}
|
||||
|
||||
/** Prefer English / Latin copy for the public website. */
|
||||
function englishText(...candidates: Array<string | null | undefined>) {
|
||||
for (const candidate of candidates) {
|
||||
const value = String(candidate ?? '').trim();
|
||||
if (value && !isMostlyPersian(value)) return value;
|
||||
}
|
||||
return '';
|
||||
}
|
||||
|
||||
function categoryAncestors(
|
||||
categoryId: string | undefined,
|
||||
byId: Map<
|
||||
string,
|
||||
{ id: string; parentId: string | null; name: string; nameFa: string | null }
|
||||
>,
|
||||
): string[] {
|
||||
if (!categoryId) return [];
|
||||
const names: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = byId.get(categoryId);
|
||||
while (current && !seen.has(current.id)) {
|
||||
seen.add(current.id);
|
||||
const label = englishText(current.name, current.nameFa);
|
||||
if (label) names.unshift(label);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return names;
|
||||
}
|
||||
|
||||
function lastTwoCategoryLevels(trail: string[]) {
|
||||
if (trail.length === 0) return null;
|
||||
return trail.slice(-2).join(' / ');
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WebsiteService {
|
||||
private readonly logger = new Logger(WebsiteService.name);
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly meshkee: MeshkeeClient,
|
||||
) {}
|
||||
|
||||
async getHomeMedia(): Promise<WebsiteHomeMedia> {
|
||||
const row = await this.prisma.websiteSetting.findUnique({
|
||||
where: { key: HOME_MEDIA_KEY },
|
||||
});
|
||||
if (!row?.value) return defaultHomeMedia();
|
||||
return this.normalizeHomeMedia(row.value);
|
||||
}
|
||||
|
||||
async saveHomeMedia(
|
||||
dto: UpdateWebsiteHomeMediaDto,
|
||||
): Promise<WebsiteHomeMedia> {
|
||||
const value = this.normalizeHomeMedia(dto);
|
||||
await this.prisma.websiteSetting.upsert({
|
||||
where: { key: HOME_MEDIA_KEY },
|
||||
create: {
|
||||
key: HOME_MEDIA_KEY,
|
||||
value: value as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
update: {
|
||||
value: value as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
async getSpecialBusinesses(): Promise<SpecialBusinessesConfig> {
|
||||
const row = await this.prisma.websiteSetting.findUnique({
|
||||
where: { key: SPECIAL_BUSINESSES_KEY },
|
||||
});
|
||||
if (!row?.value) return defaultSpecialBusinesses();
|
||||
return this.normalizeSpecialBusinesses(row.value);
|
||||
}
|
||||
|
||||
async saveSpecialBusinesses(
|
||||
dto: UpdateSpecialBusinessesDto,
|
||||
): Promise<SpecialBusinessesConfig> {
|
||||
const value = this.normalizeSpecialBusinesses(dto);
|
||||
await this.prisma.websiteSetting.upsert({
|
||||
where: { key: SPECIAL_BUSINESSES_KEY },
|
||||
create: {
|
||||
key: SPECIAL_BUSINESSES_KEY,
|
||||
value: value as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
update: {
|
||||
value: value as unknown as Prisma.InputJsonValue,
|
||||
},
|
||||
});
|
||||
return value;
|
||||
}
|
||||
|
||||
async getPublicCarousels(): Promise<WebsiteCarouselsResponse> {
|
||||
const config = await this.getSpecialBusinesses();
|
||||
const orderedIds = [
|
||||
...new Set(config.carousels.flatMap((carousel) => carousel.businessIds)),
|
||||
];
|
||||
|
||||
if (!orderedIds.length) {
|
||||
return {
|
||||
carousels: config.carousels.map((carousel) => ({
|
||||
id: carousel.id,
|
||||
title: carousel.title,
|
||||
subtext: carousel.subtext,
|
||||
slug: carousel.slug,
|
||||
businesses: [],
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
const listings = await this.prisma.business.findMany({
|
||||
where: {
|
||||
id: { in: orderedIds.map((id) => BigInt(id)) },
|
||||
status: 'published',
|
||||
},
|
||||
});
|
||||
const listingById = new Map(
|
||||
listings.map((item) => [item.id.toString(), item]),
|
||||
);
|
||||
|
||||
let categoryById = new Map<
|
||||
string,
|
||||
{ id: string; parentId: string | null; name: string; nameFa: string | null }
|
||||
>();
|
||||
try {
|
||||
const allCategories = await this.meshkee.listBusinessCategories();
|
||||
categoryById = new Map(
|
||||
allCategories.map((category) => [category.id, category]),
|
||||
);
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to load Meshkee categories for carousels: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
const cards = new Map<string, WebsiteCarouselBusiness>();
|
||||
await Promise.all(
|
||||
orderedIds.map(async (id) => {
|
||||
const listing = listingById.get(id);
|
||||
if (!listing) return;
|
||||
|
||||
const meshkeeId = listing.meshkeeBusinessId.toString();
|
||||
let name = listing.slug;
|
||||
let tagline = '';
|
||||
let about = '';
|
||||
let logoUrl: string | null = null;
|
||||
let bannerUrl: string | null = null;
|
||||
let categoryName: string | null = null;
|
||||
let productCount = 0;
|
||||
|
||||
try {
|
||||
const [meshkee, profile, products] = await Promise.all([
|
||||
this.meshkee.getBusiness(meshkeeId),
|
||||
this.meshkee.getBusinessProfile(meshkeeId),
|
||||
this.meshkee.getProductCount(meshkeeId),
|
||||
]);
|
||||
name =
|
||||
englishText(profile.nameEn, meshkee.name, listing.slug) ||
|
||||
listing.slug;
|
||||
tagline = englishText(profile.title);
|
||||
// Keep English about only — Farsi is dropped, empty stays empty
|
||||
// (website falls back to English placeholder copy).
|
||||
about = englishText(profile.about);
|
||||
logoUrl = profile.logoUrl ?? meshkee.logoUrl ?? null;
|
||||
bannerUrl = profile.bannerUrl ?? meshkee.bannerUrl ?? null;
|
||||
productCount = products;
|
||||
|
||||
const primaryCategoryId =
|
||||
profile.categoryIds[0] ?? meshkee.categories[0]?.id;
|
||||
const trail = categoryAncestors(primaryCategoryId, categoryById);
|
||||
categoryName =
|
||||
lastTwoCategoryLevels(trail) ||
|
||||
englishText(meshkee.categories[0]?.name) ||
|
||||
null;
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Failed to enrich carousel business ${id}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
}
|
||||
|
||||
cards.set(id, {
|
||||
id,
|
||||
slug: listing.slug,
|
||||
name,
|
||||
tagline,
|
||||
about,
|
||||
logoUrl,
|
||||
bannerUrl,
|
||||
categoryName,
|
||||
productCount,
|
||||
publishedAt: listing.publishedAt?.toISOString() ?? null,
|
||||
});
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
carousels: config.carousels.map((carousel) => ({
|
||||
id: carousel.id,
|
||||
title: carousel.title,
|
||||
subtext: carousel.subtext,
|
||||
slug: carousel.slug,
|
||||
businesses: carousel.businessIds
|
||||
.map((businessId) => cards.get(businessId))
|
||||
.filter((item): item is WebsiteCarouselBusiness => Boolean(item)),
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async saveUpload(file: Express.Multer.File): Promise<{ url: string }> {
|
||||
const uploadsDir = join(process.cwd(), 'uploads', 'website');
|
||||
await mkdir(uploadsDir, { recursive: true });
|
||||
|
||||
const safeExt = (extname(file.originalname) || '.jpg')
|
||||
.toLowerCase()
|
||||
.replace(/[^\w.]/g, '');
|
||||
const filename = `${Date.now()}-${randomUUID().slice(0, 8)}${safeExt}`;
|
||||
await writeFile(join(uploadsDir, filename), file.buffer);
|
||||
|
||||
return { url: `/api/v1/website/files/${filename}` };
|
||||
}
|
||||
|
||||
private normalizeHomeMedia(raw: unknown): WebsiteHomeMedia {
|
||||
const defaults = defaultHomeMedia();
|
||||
const data = (raw ?? {}) as Partial<WebsiteHomeMedia>;
|
||||
|
||||
return {
|
||||
slider: this.normalizeList(data.slider, defaults.slider, 1),
|
||||
standingBanners: this.normalizeFixed(
|
||||
data.standingBanners,
|
||||
defaults.standingBanners,
|
||||
2,
|
||||
),
|
||||
categories: this.normalizeList(data.categories, defaults.categories, 0),
|
||||
menuImages: this.normalizeFixed(data.menuImages, defaults.menuImages, 4),
|
||||
aboutUs: this.normalizeItem(data.aboutUs, defaults.aboutUs),
|
||||
banners: this.normalizeFixed(data.banners, defaults.banners, 5),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeList(
|
||||
items: WebsiteMediaItem[] | undefined,
|
||||
fallback: WebsiteMediaItem[],
|
||||
min: number,
|
||||
): WebsiteMediaItem[] {
|
||||
const list = (Array.isArray(items) ? items : [])
|
||||
.map((item, index) =>
|
||||
this.normalizeItem(item, fallback[index] ?? fallback[0]),
|
||||
)
|
||||
.filter((item) => item.imageUrl.trim());
|
||||
if (list.length >= min) return list;
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private normalizeFixed(
|
||||
items: WebsiteMediaItem[] | undefined,
|
||||
fallback: WebsiteMediaItem[],
|
||||
count: number,
|
||||
): WebsiteMediaItem[] {
|
||||
const list = Array.isArray(items) ? items : [];
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
this.normalizeItem(list[index], fallback[index] ?? fallback[0]),
|
||||
);
|
||||
}
|
||||
|
||||
private normalizeItem(
|
||||
item: Partial<WebsiteMediaItem> | undefined,
|
||||
fallback: WebsiteMediaItem,
|
||||
): WebsiteMediaItem {
|
||||
return {
|
||||
id: String(item?.id || fallback.id || randomUUID()),
|
||||
imageUrl: String(item?.imageUrl ?? fallback.imageUrl ?? '').trim(),
|
||||
linkUrl: String(item?.linkUrl ?? fallback.linkUrl ?? '').trim() || '/',
|
||||
title: (item?.title ?? fallback.title)?.trim() || undefined,
|
||||
subtitle: (item?.subtitle ?? fallback.subtitle)?.trim() || undefined,
|
||||
cta: (item?.cta ?? fallback.cta)?.trim() || undefined,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeSpecialBusinesses(raw: unknown): SpecialBusinessesConfig {
|
||||
const defaults = defaultSpecialBusinesses();
|
||||
const data = (raw ?? {}) as Partial<SpecialBusinessesConfig>;
|
||||
const list = Array.isArray(data.carousels) ? data.carousels : [];
|
||||
const carousels = list
|
||||
.map((item, index) =>
|
||||
this.normalizeCarousel(item, defaults.carousels[index]),
|
||||
)
|
||||
.filter((item) => item.title.trim());
|
||||
return {
|
||||
carousels: carousels.length ? carousels : defaults.carousels,
|
||||
};
|
||||
}
|
||||
|
||||
private normalizeCarousel(
|
||||
item: Partial<SpecialCarousel> | undefined,
|
||||
fallback?: SpecialCarousel,
|
||||
): SpecialCarousel {
|
||||
const title = String(item?.title ?? fallback?.title ?? '').trim();
|
||||
const slugBase =
|
||||
String(item?.slug ?? fallback?.slug ?? title)
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, '-')
|
||||
.replace(/^-+|-+$/g, '') || 'carousel';
|
||||
const ids = Array.isArray(item?.businessIds)
|
||||
? item.businessIds.map(String).filter(Boolean)
|
||||
: (fallback?.businessIds ?? []);
|
||||
return {
|
||||
id: String(item?.id || fallback?.id || randomUUID()),
|
||||
title: title || 'Untitled carousel',
|
||||
subtext: String(item?.subtext ?? fallback?.subtext ?? '').trim(),
|
||||
slug: slugBase,
|
||||
businessIds: [...new Set(ids)],
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"extends": "./tsconfig.json",
|
||||
"exclude": ["node_modules", "dist", "test", "**/*spec.ts"]
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"removeComments": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
"experimentalDecorators": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"target": "ES2021",
|
||||
"sourceMap": true,
|
||||
"outDir": "./dist",
|
||||
"baseUrl": "./",
|
||||
"incremental": true,
|
||||
"skipLibCheck": true,
|
||||
"strictNullChecks": true,
|
||||
"noImplicitAny": true,
|
||||
"strictBindCallApply": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"noFallthroughCasesInSwitch": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "dist"]
|
||||
}
|
||||
Reference in New Issue
Block a user