commit d5cf76ee308d673ba39629b64fa7663a426df2a6 Author: Alireza Hassani Date: Sun Oct 4 13:21:10 2026 +0330 Update carousels, card hover, and IranBay branding. Co-authored-by: Cursor diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..07cedee --- /dev/null +++ b/.env.example @@ -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= diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..d736a59 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +node_modules/ +dist/ +uploads/ +.env +.env.* +!.env.example +*.log +.DS_Store diff --git a/README.md b/README.md new file mode 100644 index 0000000..1845dd3 --- /dev/null +++ b/README.md @@ -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 | diff --git a/database/migrate.sh b/database/migrate.sh new file mode 100755 index 0000000..0396b68 --- /dev/null +++ b/database/migrate.sh @@ -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." diff --git a/database/migrations/001_categories_and_businesses.sql b/database/migrations/001_categories_and_businesses.sql new file mode 100644 index 0000000..1a6f8be --- /dev/null +++ b/database/migrations/001_categories_and_businesses.sql @@ -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; diff --git a/database/migrations/002_marketplace_categories_with_tags.sql b/database/migrations/002_marketplace_categories_with_tags.sql new file mode 100644 index 0000000..54adfbe --- /dev/null +++ b/database/migrations/002_marketplace_categories_with_tags.sql @@ -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) diff --git a/database/migrations/003_other_category.sql b/database/migrations/003_other_category.sql new file mode 100644 index 0000000..476aa42 --- /dev/null +++ b/database/migrations/003_other_category.sql @@ -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(); diff --git a/database/migrations/004_slim_businesses_meshkee_source.sql b/database/migrations/004_slim_businesses_meshkee_source.sql new file mode 100644 index 0000000..edc2ce7 --- /dev/null +++ b/database/migrations/004_slim_businesses_meshkee_source.sql @@ -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; diff --git a/database/migrations/005_minimum_listing_drop_categories.sql b/database/migrations/005_minimum_listing_drop_categories.sql new file mode 100644 index 0000000..fdd9ff2 --- /dev/null +++ b/database/migrations/005_minimum_listing_drop_categories.sql @@ -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; diff --git a/database/migrations/006_website_home_media.sql b/database/migrations/006_website_home_media.sql new file mode 100644 index 0000000..9dea792 --- /dev/null +++ b/database/migrations/006_website_home_media.sql @@ -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.'; diff --git a/database/migrations/007_website_blog.sql b/database/migrations/007_website_blog.sql new file mode 100644 index 0000000..c4ae8e0 --- /dev/null +++ b/database/migrations/007_website_blog.sql @@ -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.'; diff --git a/database/migrations/008_website_blog_category_tree.sql b/database/migrations/008_website_blog_category_tree.sql new file mode 100644 index 0000000..90bc545 --- /dev/null +++ b/database/migrations/008_website_blog_category_tree.sql @@ -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)); diff --git a/database/migrations/009_website_blog_category_details.sql b/database/migrations/009_website_blog_category_details.sql new file mode 100644 index 0000000..c86f2c5 --- /dev/null +++ b/database/migrations/009_website_blog_category_details.sql @@ -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; diff --git a/database/wait-for-postgres.sh b/database/wait-for-postgres.sh new file mode 100755 index 0000000..018d13b --- /dev/null +++ b/database/wait-for-postgres.sh @@ -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 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..05ebae2 --- /dev/null +++ b/docker-compose.yml @@ -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: diff --git a/nest-cli.json b/nest-cli.json new file mode 100644 index 0000000..f9aa683 --- /dev/null +++ b/nest-cli.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/nest-cli", + "collection": "@nestjs/schematics", + "sourceRoot": "src", + "compilerOptions": { + "deleteOutDir": true + } +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..18bd618 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,4060 @@ +{ + "name": "marketplace-api", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "marketplace-api", + "version": "0.1.0", + "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" + } + }, + "node_modules/@angular-devkit/core": { + "version": "19.2.27", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/core/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@angular-devkit/schematics": { + "version": "19.2.27", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli": { + "version": "19.2.27", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@inquirer/prompts": "7.3.2", + "ansi-colors": "4.1.3", + "symbol-observable": "4.0.0", + "yargs-parser": "21.1.1" + }, + "bin": { + "schematics": "bin/schematics.js" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@angular-devkit/schematics-cli/node_modules/@inquirer/prompts": { + "version": "7.3.2", + "resolved": "https://registry.npmjs.org/@inquirer/prompts/-/prompts-7.3.2.tgz", + "integrity": "sha512-G1ytyOoHh5BphmEBxSwALin3n1KGNYB6yImbICcRQdzXfOGbuJ9Jske/Of5Sebk339NSGGNfUshnzK8YWkTPsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.1.2", + "@inquirer/confirm": "^5.1.6", + "@inquirer/editor": "^4.2.7", + "@inquirer/expand": "^4.0.9", + "@inquirer/input": "^4.1.6", + "@inquirer/number": "^3.0.9", + "@inquirer/password": "^4.0.9", + "@inquirer/rawlist": "^4.0.9", + "@inquirer/search": "^3.0.9", + "@inquirer/select": "^4.0.9" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@angular-devkit/schematics/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@borewit/text-codec": { + "version": "0.2.2", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@colors/colors": { + "version": "1.5.0", + "dev": true, + "license": "MIT", + "optional": true, + "engines": { + "node": ">=0.1.90" + } + }, + "node_modules/@inquirer/ansi": { + "version": "1.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/checkbox": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/confirm": { + "version": "5.1.21", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/core": { + "version": "10.3.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "cli-width": "^4.1.0", + "mute-stream": "^2.0.0", + "signal-exit": "^4.1.0", + "wrap-ansi": "^6.2.0", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/editor": { + "version": "4.2.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/external-editor": "^1.0.3", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/expand": { + "version": "4.0.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/external-editor": { + "version": "1.0.3", + "dev": true, + "license": "MIT", + "dependencies": { + "chardet": "^2.1.1", + "iconv-lite": "^0.7.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/figures": { + "version": "1.0.15", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@inquirer/input": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/number": { + "version": "3.0.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/password": { + "version": "4.0.23", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/prompts": { + "version": "7.10.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/checkbox": "^4.3.2", + "@inquirer/confirm": "^5.1.21", + "@inquirer/editor": "^4.2.23", + "@inquirer/expand": "^4.0.23", + "@inquirer/input": "^4.3.1", + "@inquirer/number": "^3.0.23", + "@inquirer/password": "^4.0.23", + "@inquirer/rawlist": "^4.1.11", + "@inquirer/search": "^3.2.2", + "@inquirer/select": "^4.4.2" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/rawlist": { + "version": "4.1.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/search": { + "version": "3.2.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/select": { + "version": "4.4.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@inquirer/ansi": "^1.0.2", + "@inquirer/core": "^10.3.2", + "@inquirer/figures": "^1.0.15", + "@inquirer/type": "^3.0.10", + "yoctocolors-cjs": "^2.1.3" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@inquirer/type": { + "version": "3.0.10", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@types/node": ">=18" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/source-map": { + "version": "0.3.11", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.25" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.6.0", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@lukeed/csprng": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/@nestjs/cli": { + "version": "11.0.24", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.27", + "@angular-devkit/schematics": "19.2.27", + "@angular-devkit/schematics-cli": "19.2.27", + "@inquirer/prompts": "7.10.1", + "@nestjs/schematics": "^11.0.1", + "ansis": "4.2.0", + "chokidar": "4.0.3", + "cli-table3": "0.6.5", + "commander": "4.1.1", + "fork-ts-checker-webpack-plugin": "9.1.0", + "glob": "13.0.6", + "node-emoji": "1.11.0", + "ora": "5.4.1", + "tsconfig-paths": "4.2.0", + "tsconfig-paths-webpack-plugin": "4.2.0", + "typescript": "5.9.3", + "webpack": "5.106.2", + "webpack-node-externals": "3.0.0" + }, + "bin": { + "nest": "bin/nest.js" + }, + "engines": { + "node": ">= 20.11" + }, + "peerDependencies": { + "@swc/cli": "^0.1.62 || ^0.3.0 || ^0.4.0 || ^0.5.0 || ^0.6.0 || ^0.7.0 || ^0.8.0", + "@swc/core": "^1.3.62" + }, + "peerDependenciesMeta": { + "@swc/cli": { + "optional": true + }, + "@swc/core": { + "optional": true + } + } + }, + "node_modules/@nestjs/common": { + "version": "11.2.3", + "license": "MIT", + "dependencies": { + "file-type": "21.3.4", + "iterare": "1.2.1", + "load-esm": "1.0.3", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "class-transformer": ">=0.4.1", + "class-validator": ">=0.13.2", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "class-transformer": { + "optional": true + }, + "class-validator": { + "optional": true + } + } + }, + "node_modules/@nestjs/config": { + "version": "4.0.4", + "license": "MIT", + "dependencies": { + "dotenv": "17.4.1", + "dotenv-expand": "12.0.3", + "lodash": "4.18.1" + }, + "peerDependencies": { + "@nestjs/common": "^10.0.0 || ^11.0.0", + "rxjs": "^7.1.0" + } + }, + "node_modules/@nestjs/core": { + "version": "11.2.3", + "license": "MIT", + "dependencies": { + "fast-safe-stringify": "2.1.1", + "iterare": "1.2.1", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1", + "uid": "2.0.2" + }, + "engines": { + "node": ">= 20" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/microservices": "^11.0.0", + "@nestjs/platform-express": "^11.0.0", + "@nestjs/websockets": "^11.0.0", + "reflect-metadata": "^0.1.12 || ^0.2.0", + "rxjs": "^7.1.0" + }, + "peerDependenciesMeta": { + "@nestjs/microservices": { + "optional": true + }, + "@nestjs/platform-express": { + "optional": true + }, + "@nestjs/websockets": { + "optional": true + } + } + }, + "node_modules/@nestjs/platform-express": { + "version": "11.2.3", + "license": "MIT", + "dependencies": { + "cors": "2.8.6", + "express": "5.2.1", + "multer": "2.2.0", + "path-to-regexp": "8.4.2", + "tslib": "2.8.1" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/nest" + }, + "peerDependencies": { + "@nestjs/common": "^11.0.0", + "@nestjs/core": "^11.0.0" + } + }, + "node_modules/@nestjs/schematics": { + "version": "11.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "@angular-devkit/schematics": "19.2.24", + "comment-json": "5.0.0", + "jsonc-parser": "3.3.1", + "pluralize": "8.0.0" + }, + "peerDependencies": { + "prettier": "^3.0.0", + "typescript": ">=4.8.2" + }, + "peerDependenciesMeta": { + "prettier": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/core": { + "version": "19.2.24", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "8.18.0", + "ajv-formats": "3.0.1", + "jsonc-parser": "3.3.1", + "picomatch": "4.0.4", + "rxjs": "7.8.1", + "source-map": "0.7.4" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + }, + "peerDependencies": { + "chokidar": "^4.0.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/@nestjs/schematics/node_modules/@angular-devkit/schematics": { + "version": "19.2.24", + "dev": true, + "license": "MIT", + "dependencies": { + "@angular-devkit/core": "19.2.24", + "jsonc-parser": "3.3.1", + "magic-string": "0.30.17", + "ora": "5.4.1", + "rxjs": "7.8.1" + }, + "engines": { + "node": "^18.19.1 || ^20.11.1 || >=22.0.0", + "npm": "^6.11.0 || ^7.5.6 || >=8.0.0", + "yarn": ">= 1.13.0" + } + }, + "node_modules/@nestjs/schematics/node_modules/rxjs": { + "version": "7.8.1", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/@prisma/client": { + "version": "6.19.3", + "resolved": "https://registry.npmjs.org/@prisma/client/-/client-6.19.3.tgz", + "integrity": "sha512-mKq3jQFhjvko5LTJFHGilsuQs+W+T3Gm451NzuTDGQxwCzwXHYnIu2zGkRoW+Exq3Rob7yp2MfzSrdIiZVhrBg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "prisma": "*", + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "prisma": { + "optional": true + }, + "typescript": { + "optional": true + } + } + }, + "node_modules/@prisma/config": { + "version": "6.19.3", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "c12": "3.1.0", + "deepmerge-ts": "7.1.5", + "effect": "3.21.0", + "empathic": "2.0.0" + } + }, + "node_modules/@prisma/debug": { + "version": "6.19.3", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/engines": { + "version": "6.19.3", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/fetch-engine": "6.19.3", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/engines-version": { + "version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "devOptional": true, + "license": "Apache-2.0" + }, + "node_modules/@prisma/fetch-engine": { + "version": "6.19.3", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3", + "@prisma/engines-version": "7.1.1-3.c2990dca591cba766e3b7ef5d9e8a84796e47ab7", + "@prisma/get-platform": "6.19.3" + } + }, + "node_modules/@prisma/get-platform": { + "version": "6.19.3", + "devOptional": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/debug": "6.19.3" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/@tokenizer/inflate": { + "version": "0.4.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "token-types": "^6.1.1" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/@tokenizer/token": { + "version": "0.3.0", + "license": "MIT" + }, + "node_modules/@types/body-parser": { + "version": "1.19.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/connect": "*", + "@types/node": "*" + } + }, + "node_modules/@types/connect": { + "version": "3.4.38", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/eslint": { + "version": "9.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "*", + "@types/json-schema": "*" + } + }, + "node_modules/@types/eslint-scope": { + "version": "3.7.7", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint": "*", + "@types/estree": "*" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/express": { + "version": "5.0.6", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/body-parser": "*", + "@types/express-serve-static-core": "^5.0.0", + "@types/serve-static": "^2" + } + }, + "node_modules/@types/express-serve-static-core": { + "version": "5.1.3", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "@types/qs": "*", + "@types/range-parser": "*", + "@types/send": "*" + } + }, + "node_modules/@types/http-errors": { + "version": "2.0.5", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/multer": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.3.0.tgz", + "integrity": "sha512-i7STIm9V4K2MPH4ZYMtrZAwNxs3kglk2LgleaTuB9pUXgADBcQYQuYMd7+6xgTIxfoggIkFA/DkkvycdZpIARA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, + "node_modules/@types/node": { + "version": "22.20.2", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/qs": { + "version": "6.15.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/range-parser": { + "version": "1.2.7", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/send": { + "version": "1.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, + "node_modules/@types/serve-static": { + "version": "2.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/http-errors": "*", + "@types/node": "*" + } + }, + "node_modules/@types/validator": { + "version": "13.15.10", + "license": "MIT" + }, + "node_modules/@webassemblyjs/ast": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/helper-numbers": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2" + } + }, + "node_modules/@webassemblyjs/floating-point-hex-parser": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-api-error": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-buffer": { + "version": "1.14.1", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-numbers": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/floating-point-hex-parser": "1.13.2", + "@webassemblyjs/helper-api-error": "1.13.2", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/helper-wasm-bytecode": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/helper-wasm-section": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/wasm-gen": "1.14.1" + } + }, + "node_modules/@webassemblyjs/ieee754": { + "version": "1.13.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@xtuc/ieee754": "^1.2.0" + } + }, + "node_modules/@webassemblyjs/leb128": { + "version": "1.13.2", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@webassemblyjs/utf8": { + "version": "1.13.2", + "dev": true, + "license": "MIT" + }, + "node_modules/@webassemblyjs/wasm-edit": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/helper-wasm-section": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-opt": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1", + "@webassemblyjs/wast-printer": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-gen": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wasm-opt": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-buffer": "1.14.1", + "@webassemblyjs/wasm-gen": "1.14.1", + "@webassemblyjs/wasm-parser": "1.14.1" + } + }, + "node_modules/@webassemblyjs/wasm-parser": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@webassemblyjs/helper-api-error": "1.13.2", + "@webassemblyjs/helper-wasm-bytecode": "1.13.2", + "@webassemblyjs/ieee754": "1.13.2", + "@webassemblyjs/leb128": "1.13.2", + "@webassemblyjs/utf8": "1.13.2" + } + }, + "node_modules/@webassemblyjs/wast-printer": { + "version": "1.14.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@webassemblyjs/ast": "1.14.1", + "@xtuc/long": "4.2.2" + } + }, + "node_modules/@xtuc/ieee754": { + "version": "1.2.0", + "dev": true, + "license": "BSD-3-Clause" + }, + "node_modules/@xtuc/long": { + "version": "4.2.2", + "dev": true, + "license": "Apache-2.0" + }, + "node_modules/accepts": { + "version": "2.0.0", + "license": "MIT", + "dependencies": { + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/acorn": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-import-phases": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + }, + "peerDependencies": { + "acorn": "^8.14.0" + } + }, + "node_modules/ajv": { + "version": "8.18.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ajv-formats": { + "version": "3.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^8.0.0" + }, + "peerDependencies": { + "ajv": "^8.0.0" + }, + "peerDependenciesMeta": { + "ajv": { + "optional": true + } + } + }, + "node_modules/ajv-keywords": { + "version": "5.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3" + }, + "peerDependencies": { + "ajv": "^8.8.2" + } + }, + "node_modules/ansi-colors": { + "version": "4.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/ansis": { + "version": "4.2.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + } + }, + "node_modules/append-field": { + "version": "1.0.0", + "license": "MIT" + }, + "node_modules/argparse": { + "version": "2.0.1", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/array-timsort": { + "version": "1.0.3", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "dev": true, + "license": "MIT" + }, + "node_modules/base64-js": { + "version": "1.5.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.11.21", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bl": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer": "^5.5.0", + "inherits": "^2.0.4", + "readable-stream": "^3.4.0" + } + }, + "node_modules/body-parser": { + "version": "2.3.0", + "license": "MIT", + "dependencies": { + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.18", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/browserslist": { + "version": "4.28.9", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.11.20", + "caniuse-lite": "^1.0.30001810", + "electron-to-chromium": "^1.5.420", + "node-releases": "^2.0.54", + "update-browserslist-db": "^1.3.2" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/buffer": { + "version": "5.7.1", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.1.13" + } + }, + "node_modules/buffer-from": { + "version": "1.1.2", + "license": "MIT" + }, + "node_modules/busboy": { + "version": "1.6.0", + "dependencies": { + "streamsearch": "^1.1.0" + }, + "engines": { + "node": ">=10.16.0" + } + }, + "node_modules/bytes": { + "version": "3.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/c12": { + "version": "3.1.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "chokidar": "^4.0.3", + "confbox": "^0.2.2", + "defu": "^6.1.4", + "dotenv": "^16.6.1", + "exsolve": "^1.0.7", + "giget": "^2.0.0", + "jiti": "^2.4.2", + "ohash": "^2.0.11", + "pathe": "^2.0.3", + "perfect-debounce": "^1.0.0", + "pkg-types": "^2.2.0", + "rc9": "^2.1.2" + }, + "peerDependencies": { + "magicast": "^0.3.5" + }, + "peerDependenciesMeta": { + "magicast": { + "optional": true + } + } + }, + "node_modules/c12/node_modules/dotenv": { + "version": "16.6.1", + "devOptional": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/callsites": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001810", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/chardet": { + "version": "2.2.0", + "dev": true, + "license": "MIT" + }, + "node_modules/chokidar": { + "version": "4.0.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "readdirp": "^4.0.1" + }, + "engines": { + "node": ">= 14.16.0" + }, + "funding": { + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/chrome-trace-event": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0" + } + }, + "node_modules/citty": { + "version": "0.1.6", + "devOptional": true, + "license": "MIT", + "dependencies": { + "consola": "^3.2.3" + } + }, + "node_modules/class-transformer": { + "version": "0.5.1", + "license": "MIT" + }, + "node_modules/class-validator": { + "version": "0.14.4", + "license": "MIT", + "dependencies": { + "@types/validator": "^13.15.3", + "libphonenumber-js": "^1.11.1", + "validator": "^13.15.22" + } + }, + "node_modules/cli-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "restore-cursor": "^3.1.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/cli-spinners": { + "version": "2.9.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/cli-table3": { + "version": "0.6.5", + "dev": true, + "license": "MIT", + "dependencies": { + "string-width": "^4.2.0" + }, + "engines": { + "node": "10.* || >= 12.*" + }, + "optionalDependencies": { + "@colors/colors": "1.5.0" + } + }, + "node_modules/cli-width": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 12" + } + }, + "node_modules/clone": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "dev": true, + "license": "MIT" + }, + "node_modules/commander": { + "version": "4.1.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/comment-json": { + "version": "5.0.0", + "dev": true, + "license": "MIT", + "dependencies": { + "array-timsort": "^1.0.3", + "esprima": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "dev": true, + "license": "MIT" + }, + "node_modules/concat-stream": { + "version": "2.0.0", + "engines": [ + "node >= 6.0" + ], + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "inherits": "^2.0.3", + "readable-stream": "^3.0.2", + "typedarray": "^0.0.6" + } + }, + "node_modules/confbox": { + "version": "0.2.4", + "devOptional": true, + "license": "MIT" + }, + "node_modules/consola": { + "version": "3.4.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": "^14.18.0 || >=16.10.0" + } + }, + "node_modules/content-disposition": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/content-type": { + "version": "1.0.5", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie": { + "version": "0.7.2", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/cookie-signature": { + "version": "1.2.2", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } + }, + "node_modules/cors": { + "version": "2.8.6", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cosmiconfig": { + "version": "8.3.6", + "dev": true, + "license": "MIT", + "dependencies": { + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0", + "path-type": "^4.0.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/debug": { + "version": "4.4.3", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deepmerge": { + "version": "4.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/deepmerge-ts": { + "version": "7.1.5", + "devOptional": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/defaults": { + "version": "1.0.4", + "dev": true, + "license": "MIT", + "dependencies": { + "clone": "^1.0.2" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/defu": { + "version": "6.1.7", + "devOptional": true, + "license": "MIT" + }, + "node_modules/depd": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/destr": { + "version": "2.0.5", + "devOptional": true, + "license": "MIT" + }, + "node_modules/dotenv": { + "version": "17.4.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand": { + "version": "12.0.3", + "license": "BSD-2-Clause", + "dependencies": { + "dotenv": "^16.4.5" + }, + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dotenv-expand/node_modules/dotenv": { + "version": "16.6.1", + "license": "BSD-2-Clause", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://dotenvx.com" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/ee-first": { + "version": "1.1.1", + "license": "MIT" + }, + "node_modules/effect": { + "version": "3.21.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "fast-check": "^3.23.1" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.426", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/empathic": { + "version": "2.0.0", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=14" + } + }, + "node_modules/encodeurl": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.24.5", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-module-lexer": { + "version": "2.3.2", + "dev": true, + "license": "MIT" + }, + "node_modules/es-object-atoms": { + "version": "1.1.2", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-html": { + "version": "1.0.3", + "license": "MIT" + }, + "node_modules/eslint-scope": { + "version": "5.1.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^4.1.1" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/esprima": { + "version": "4.0.1", + "dev": true, + "license": "BSD-2-Clause", + "bin": { + "esparse": "bin/esparse.js", + "esvalidate": "bin/esvalidate.js" + }, + "engines": { + "node": ">=4" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esrecurse/node_modules/estraverse": { + "version": "5.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "4.3.0", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/etag": { + "version": "1.8.1", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/events": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.x" + } + }, + "node_modules/express": { + "version": "5.2.1", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/exsolve": { + "version": "1.1.1", + "devOptional": true, + "license": "MIT" + }, + "node_modules/fast-check": { + "version": "3.23.2", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT", + "dependencies": { + "pure-rand": "^6.1.0" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "license": "MIT" + }, + "node_modules/fast-uri": { + "version": "3.1.7", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/file-type": { + "version": "21.3.4", + "license": "MIT", + "dependencies": { + "@tokenizer/inflate": "^0.4.1", + "strtok3": "^10.3.4", + "token-types": "^6.1.1", + "uint8array-extras": "^1.4.0" + }, + "engines": { + "node": ">=20" + }, + "funding": { + "url": "https://github.com/sindresorhus/file-type?sponsor=1" + } + }, + "node_modules/finalhandler": { + "version": "2.1.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" + }, + "engines": { + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/fork-ts-checker-webpack-plugin": { + "version": "9.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.16.7", + "chalk": "^4.1.2", + "chokidar": "^4.0.1", + "cosmiconfig": "^8.2.0", + "deepmerge": "^4.2.2", + "fs-extra": "^10.0.0", + "memfs": "^3.4.1", + "minimatch": "^3.0.4", + "node-abort-controller": "^3.0.1", + "schema-utils": "^3.1.1", + "semver": "^7.3.5", + "tapable": "^2.2.1" + }, + "engines": { + "node": ">=14.21.3" + }, + "peerDependencies": { + "typescript": ">3.6.0", + "webpack": "^5.11.0" + } + }, + "node_modules/forwarded": { + "version": "0.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/fresh": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/fs-extra": { + "version": "10.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.0", + "jsonfile": "^6.0.1", + "universalify": "^2.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/fs-monkey": { + "version": "1.1.0", + "dev": true, + "license": "Unlicense" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.1.1", + "function-bind": "^1.1.2", + "get-proto": "^1.0.1", + "gopd": "^1.2.0", + "has-symbols": "^1.1.0", + "hasown": "^2.0.2", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/giget": { + "version": "2.0.0", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.1.6", + "consola": "^3.4.0", + "defu": "^6.1.4", + "node-fetch-native": "^1.6.6", + "nypm": "^0.6.0", + "pathe": "^2.0.3" + }, + "bin": { + "giget": "dist/cli.mjs" + } + }, + "node_modules/glob": { + "version": "13.0.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "minimatch": "^10.2.2", + "minipass": "^7.1.3", + "path-scurry": "^2.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-to-regexp": { + "version": "0.4.1", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/glob/node_modules/balanced-match": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/glob/node_modules/brace-expansion": { + "version": "5.0.9", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/glob/node_modules/minimatch": { + "version": "10.2.6", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.8" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.4", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/http-errors": { + "version": "2.0.1", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/iconv-lite": { + "version": "0.7.3", + "license": "MIT", + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/ieee754": { + "version": "1.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "license": "ISC" + }, + "node_modules/ipaddr.js": { + "version": "1.9.1", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "dev": true, + "license": "MIT" + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-interactive": { + "version": "1.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-promise": { + "version": "4.0.0", + "license": "MIT" + }, + "node_modules/is-unicode-supported": { + "version": "0.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/iterare": { + "version": "1.2.1", + "license": "ISC", + "engines": { + "node": ">=6" + } + }, + "node_modules/jest-worker": { + "version": "27.5.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/node": "*", + "merge-stream": "^2.0.0", + "supports-color": "^8.0.0" + }, + "engines": { + "node": ">= 10.13.0" + } + }, + "node_modules/jest-worker/node_modules/supports-color": { + "version": "8.1.1", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/supports-color?sponsor=1" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "devOptional": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.3.2", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ], + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "1.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsonc-parser": { + "version": "3.3.1", + "dev": true, + "license": "MIT" + }, + "node_modules/jsonfile": { + "version": "6.2.1", + "dev": true, + "license": "MIT", + "dependencies": { + "universalify": "^2.0.0" + }, + "optionalDependencies": { + "graceful-fs": "^4.1.6" + } + }, + "node_modules/libphonenumber-js": { + "version": "1.13.13", + "license": "MIT" + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "dev": true, + "license": "MIT" + }, + "node_modules/load-esm": { + "version": "1.0.3", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + }, + { + "type": "buymeacoffee", + "url": "https://buymeacoffee.com/borewit" + } + ], + "license": "MIT", + "engines": { + "node": ">=13.2.0" + } + }, + "node_modules/loader-runner": { + "version": "4.3.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.11.5" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "license": "MIT" + }, + "node_modules/log-symbols": { + "version": "4.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "is-unicode-supported": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "11.5.2", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/magic-string": { + "version": "0.30.17", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/media-typer": { + "version": "1.1.1", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/memfs": { + "version": "3.5.3", + "dev": true, + "license": "Unlicense", + "dependencies": { + "fs-monkey": "^1.0.4" + }, + "engines": { + "node": ">= 4.0.0" + } + }, + "node_modules/merge-descriptors": { + "version": "2.0.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/merge-stream": { + "version": "2.0.0", + "dev": true, + "license": "MIT" + }, + "node_modules/mime-db": { + "version": "1.54.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/mimic-fn": { + "version": "2.1.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/minipass": { + "version": "7.1.3", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": ">=16 || 14 >=14.17" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "license": "MIT" + }, + "node_modules/multer": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "append-field": "^1.0.0", + "busboy": "^1.6.0", + "concat-stream": "^2.0.0", + "type-is": "^1.6.18" + }, + "engines": { + "node": ">= 10.16.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/multer/node_modules/media-typer": { + "version": "0.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-db": { + "version": "1.52.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/mime-types": { + "version": "2.1.35", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/multer/node_modules/type-is": { + "version": "1.6.18", + "license": "MIT", + "dependencies": { + "media-typer": "0.3.0", + "mime-types": "~2.1.24" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mute-stream": { + "version": "2.0.0", + "dev": true, + "license": "ISC", + "engines": { + "node": "^18.17.0 || >=20.5.0" + } + }, + "node_modules/negotiator": { + "version": "1.1.0", + "license": "MIT", + "dependencies": { + "content-type": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/negotiator/node_modules/content-type": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/neo-async": { + "version": "2.6.2", + "dev": true, + "license": "MIT" + }, + "node_modules/node-abort-controller": { + "version": "3.1.1", + "dev": true, + "license": "MIT" + }, + "node_modules/node-emoji": { + "version": "1.11.0", + "dev": true, + "license": "MIT", + "dependencies": { + "lodash": "^4.17.21" + } + }, + "node_modules/node-fetch-native": { + "version": "1.6.7", + "devOptional": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.55", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm": { + "version": "0.6.10", + "devOptional": true, + "license": "MIT", + "dependencies": { + "citty": "^0.2.2", + "pathe": "^2.0.3", + "tinyexec": "^1.3.1" + }, + "bin": { + "nypm": "dist/cli.mjs" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/nypm/node_modules/citty": { + "version": "0.2.2", + "resolved": "https://registry.npmjs.org/citty/-/citty-0.2.2.tgz", + "integrity": "sha512-+6vJA3L98yv+IdfKGZHBNiGW5KHn22e/JwID0Strsz8h4S/csAu/OuICwxrg44k5MRiZHWIo8XXuJgQTriRP4w==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/object-assign": { + "version": "4.1.1", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/ohash": { + "version": "2.0.12", + "devOptional": true, + "license": "MIT" + }, + "node_modules/on-finished": { + "version": "2.4.1", + "license": "MIT", + "dependencies": { + "ee-first": "1.1.1" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/once": { + "version": "1.4.0", + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/onetime": { + "version": "5.1.2", + "dev": true, + "license": "MIT", + "dependencies": { + "mimic-fn": "^2.1.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/ora": { + "version": "5.4.1", + "dev": true, + "license": "MIT", + "dependencies": { + "bl": "^4.1.0", + "chalk": "^4.1.0", + "cli-cursor": "^3.1.0", + "cli-spinners": "^2.5.0", + "is-interactive": "^1.0.0", + "is-unicode-supported": "^0.1.0", + "log-symbols": "^4.1.0", + "strip-ansi": "^6.0.0", + "wcwidth": "^1.0.1" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.0.0", + "error-ex": "^1.3.1", + "json-parse-even-better-errors": "^2.3.0", + "lines-and-columns": "^1.1.6" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parseurl": { + "version": "1.3.3", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/path-scurry": { + "version": "2.0.2", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "lru-cache": "^11.0.0", + "minipass": "^7.1.2" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/path-to-regexp": { + "version": "8.4.2", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/path-type": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "devOptional": true, + "license": "MIT" + }, + "node_modules/perfect-debounce": { + "version": "1.0.0", + "devOptional": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pkg-types": { + "version": "2.3.3", + "devOptional": true, + "license": "MIT", + "dependencies": { + "confbox": "^0.3.1", + "exsolve": "^1.1.1", + "pathe": "^2.0.3" + } + }, + "node_modules/pkg-types/node_modules/confbox": { + "version": "0.3.1", + "devOptional": true, + "license": "MIT" + }, + "node_modules/pluralize": { + "version": "8.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/prisma": { + "version": "6.19.3", + "devOptional": true, + "hasInstallScript": true, + "license": "Apache-2.0", + "dependencies": { + "@prisma/config": "6.19.3", + "@prisma/engines": "6.19.3" + }, + "bin": { + "prisma": "build/index.js" + }, + "engines": { + "node": ">=18.18" + }, + "peerDependencies": { + "typescript": ">=5.1.0" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/proxy-addr": { + "version": "2.0.7", + "license": "MIT", + "dependencies": { + "forwarded": "0.2.0", + "ipaddr.js": "1.9.1" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/pure-rand": { + "version": "6.1.0", + "devOptional": true, + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/dubzzz" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fast-check" + } + ], + "license": "MIT" + }, + "node_modules/qs": { + "version": "6.16.0", + "license": "BSD-3-Clause", + "dependencies": { + "es-define-property": "^1.0.1", + "side-channel": "^1.1.1" + }, + "engines": { + "node": ">=0.6" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/range-parser": { + "version": "1.3.0", + "license": "MIT", + "engines": { + "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body": { + "version": "3.0.2", + "license": "MIT", + "dependencies": { + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.7.0", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/rc9": { + "version": "2.1.2", + "devOptional": true, + "license": "MIT", + "dependencies": { + "defu": "^6.1.4", + "destr": "^2.0.3" + } + }, + "node_modules/readable-stream": { + "version": "3.6.2", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/readdirp": { + "version": "4.1.2", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">= 14.18.0" + }, + "funding": { + "type": "individual", + "url": "https://paulmillr.com/funding/" + } + }, + "node_modules/reflect-metadata": { + "version": "0.2.2", + "license": "Apache-2.0" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/restore-cursor": { + "version": "3.1.0", + "dev": true, + "license": "MIT", + "dependencies": { + "onetime": "^5.1.0", + "signal-exit": "^3.0.2" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/restore-cursor/node_modules/signal-exit": { + "version": "3.0.7", + "dev": true, + "license": "ISC" + }, + "node_modules/router": { + "version": "2.2.0", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/rxjs": { + "version": "7.8.2", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.1.0" + } + }, + "node_modules/safe-buffer": { + "version": "5.2.1", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "license": "MIT" + }, + "node_modules/schema-utils": { + "version": "3.3.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.8", + "ajv": "^6.12.5", + "ajv-keywords": "^3.5.2" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/schema-utils/node_modules/ajv": { + "version": "6.15.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/schema-utils/node_modules/ajv-keywords": { + "version": "3.5.2", + "dev": true, + "license": "MIT", + "peerDependencies": { + "ajv": "^6.9.1" + } + }, + "node_modules/schema-utils/node_modules/json-schema-traverse": { + "version": "0.4.1", + "dev": true, + "license": "MIT" + }, + "node_modules/semver": { + "version": "7.8.5", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/send": { + "version": "1.2.1", + "license": "MIT", + "dependencies": { + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/serve-static": { + "version": "2.2.1", + "license": "MIT", + "dependencies": { + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/setprototypeof": { + "version": "1.2.0", + "license": "ISC" + }, + "node_modules/side-channel": { + "version": "1.1.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4", + "side-channel-list": "^1.0.1", + "side-channel-map": "^1.0.1", + "side-channel-weakmap": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3", + "side-channel-map": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/signal-exit": { + "version": "4.1.0", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/source-map": { + "version": "0.7.4", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">= 8" + } + }, + "node_modules/source-map-support": { + "version": "0.5.21", + "dev": true, + "license": "MIT", + "dependencies": { + "buffer-from": "^1.0.0", + "source-map": "^0.6.0" + } + }, + "node_modules/source-map-support/node_modules/source-map": { + "version": "0.6.1", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/statuses": { + "version": "2.0.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/streamsearch": { + "version": "1.1.0", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/string_decoder": { + "version": "1.3.0", + "license": "MIT", + "dependencies": { + "safe-buffer": "~5.2.0" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strtok3": { + "version": "10.3.5", + "license": "MIT", + "dependencies": { + "@tokenizer/token": "^0.3.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-observable": { + "version": "4.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10" + } + }, + "node_modules/tapable": { + "version": "2.3.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser": { + "version": "5.51.2", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@jridgewell/source-map": "^0.3.3", + "acorn": "^8.15.0", + "commander": "^2.20.0", + "source-map-support": "~0.5.20" + }, + "bin": { + "terser": "bin/terser" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/terser-webpack-plugin": { + "version": "5.6.1", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.25", + "jest-worker": "^27.4.5", + "schema-utils": "^4.3.0", + "terser": "^5.31.1" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependencies": { + "webpack": "^5.1.0" + }, + "peerDependenciesMeta": { + "@minify-html/node": { + "optional": true + }, + "@swc/core": { + "optional": true + }, + "@swc/css": { + "optional": true + }, + "@swc/html": { + "optional": true + }, + "clean-css": { + "optional": true + }, + "cssnano": { + "optional": true + }, + "csso": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "html-minifier-terser": { + "optional": true + }, + "lightningcss": { + "optional": true + }, + "postcss": { + "optional": true + }, + "uglify-js": { + "optional": true + } + } + }, + "node_modules/terser-webpack-plugin/node_modules/ajv": { + "version": "8.20.0", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/terser-webpack-plugin/node_modules/schema-utils": { + "version": "4.4.0", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/terser/node_modules/commander": { + "version": "2.20.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-2.20.3.tgz", + "integrity": "sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.3.1", + "devOptional": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/toidentifier": { + "version": "1.0.1", + "license": "MIT", + "engines": { + "node": ">=0.6" + } + }, + "node_modules/token-types": { + "version": "6.1.2", + "license": "MIT", + "dependencies": { + "@borewit/text-codec": "^0.2.1", + "@tokenizer/token": "^0.3.0", + "ieee754": "^1.2.1" + }, + "engines": { + "node": ">=14.16" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Borewit" + } + }, + "node_modules/tsconfig-paths": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "json5": "^2.2.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/tsconfig-paths-webpack-plugin": { + "version": "4.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "chalk": "^4.1.0", + "enhanced-resolve": "^5.7.0", + "tapable": "^2.2.1", + "tsconfig-paths": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "license": "0BSD" + }, + "node_modules/type-is": { + "version": "2.1.0", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/type-is/node_modules/content-type": { + "version": "2.1.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/typedarray": { + "version": "0.0.6", + "license": "MIT" + }, + "node_modules/typescript": { + "version": "5.9.3", + "devOptional": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/uid": { + "version": "2.0.2", + "license": "MIT", + "dependencies": { + "@lukeed/csprng": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/uint8array-extras": { + "version": "1.5.0", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "dev": true, + "license": "MIT" + }, + "node_modules/universalify": { + "version": "2.0.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 10.0.0" + } + }, + "node_modules/unpipe": { + "version": "1.0.0", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.3.2", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/util-deprecate": { + "version": "1.0.2", + "license": "MIT" + }, + "node_modules/validator": { + "version": "13.15.35", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, + "node_modules/vary": { + "version": "1.1.2", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/watchpack": { + "version": "2.5.2", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.1.2" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/wcwidth": { + "version": "1.0.1", + "dev": true, + "license": "MIT", + "dependencies": { + "defaults": "^1.0.3" + } + }, + "node_modules/webpack": { + "version": "5.106.2", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/eslint-scope": "^3.7.7", + "@types/estree": "^1.0.8", + "@types/json-schema": "^7.0.15", + "@webassemblyjs/ast": "^1.14.1", + "@webassemblyjs/wasm-edit": "^1.14.1", + "@webassemblyjs/wasm-parser": "^1.14.1", + "acorn": "^8.16.0", + "acorn-import-phases": "^1.0.3", + "browserslist": "^4.28.1", + "chrome-trace-event": "^1.0.2", + "enhanced-resolve": "^5.20.0", + "es-module-lexer": "^2.0.0", + "eslint-scope": "5.1.1", + "events": "^3.2.0", + "glob-to-regexp": "^0.4.1", + "graceful-fs": "^4.2.11", + "loader-runner": "^4.3.1", + "mime-db": "^1.54.0", + "neo-async": "^2.6.2", + "schema-utils": "^4.3.3", + "tapable": "^2.3.0", + "terser-webpack-plugin": "^5.3.17", + "watchpack": "^2.5.1", + "webpack-sources": "^3.3.4" + }, + "bin": { + "webpack": "bin/webpack.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + }, + "peerDependenciesMeta": { + "webpack-cli": { + "optional": true + } + } + }, + "node_modules/webpack-node-externals": { + "version": "3.0.0", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/webpack-sources": { + "version": "3.5.1", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/webpack/node_modules/ajv": { + "version": "8.20.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.20.0.tgz", + "integrity": "sha512-Thbli+OlOj+iMPYFBVBfJ3OmCAnaSyNn4M1vz9T6Gka5Jt9ba/HIR56joy65tY6kx/FCF5VXNB819Y7/GUrBGA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.3", + "fast-uri": "^3.0.1", + "json-schema-traverse": "^1.0.0", + "require-from-string": "^2.0.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/webpack/node_modules/schema-utils": { + "version": "4.4.0", + "resolved": "https://registry.npmjs.org/schema-utils/-/schema-utils-4.4.0.tgz", + "integrity": "sha512-ZzWFVzFgyRHi/T2Ecxm37lL6J9+hZO0P9L7LCuLxAbC6XWxkvxcaQBCXhQxMGn/Tdt9nD7zIBk0ELwhMQ3UEDg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json-schema": "^7.0.15", + "ajv": "^8.20.0", + "ajv-formats": "^3.0.1", + "ajv-keywords": "^5.1.0" + }, + "engines": { + "node": ">= 10.13.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/wrap-ansi": { + "version": "6.2.0", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "license": "ISC" + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yoctocolors-cjs": { + "version": "2.1.3", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..34fee32 --- /dev/null +++ b/package.json @@ -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" + } +} diff --git a/prisma/schema.prisma b/prisma/schema.prisma new file mode 100644 index 0000000..a8c7d97 --- /dev/null +++ b/prisma/schema.prisma @@ -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") +} diff --git a/scripts/bootstrap-iranbay-platform.mjs b/scripts/bootstrap-iranbay-platform.mjs new file mode 100644 index 0000000..400350f --- /dev/null +++ b/scripts/bootstrap-iranbay-platform.mjs @@ -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); +}); diff --git a/src/app.module.ts b/src/app.module.ts new file mode 100644 index 0000000..402daf8 --- /dev/null +++ b/src/app.module.ts @@ -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 {} diff --git a/src/businesses/businesses.controller.ts b/src/businesses/businesses.controller.ts new file mode 100644 index 0000000..94c4afc --- /dev/null +++ b/src/businesses/businesses.controller.ts @@ -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); + } +} diff --git a/src/businesses/businesses.module.ts b/src/businesses/businesses.module.ts new file mode 100644 index 0000000..2d0e29b --- /dev/null +++ b/src/businesses/businesses.module.ts @@ -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 {} diff --git a/src/businesses/businesses.service.ts b/src/businesses/businesses.service.ts new file mode 100644 index 0000000..d8efff9 --- /dev/null +++ b/src/businesses/businesses.service.ts @@ -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`); + } + } +} diff --git a/src/businesses/dto/business.dto.ts b/src/businesses/dto/business.dto.ts new file mode 100644 index 0000000..388beac --- /dev/null +++ b/src/businesses/dto/business.dto.ts @@ -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; +} diff --git a/src/common/interceptors/bigint-serializer.interceptor.ts b/src/common/interceptors/bigint-serializer.interceptor.ts new file mode 100644 index 0000000..cb0b942 --- /dev/null +++ b/src/common/interceptors/bigint-serializer.interceptor.ts @@ -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 = {}; + 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 { + return next.handle().pipe(map((data) => serialize(data))); + } +} diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..ab67b8f --- /dev/null +++ b/src/main.ts @@ -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(); diff --git a/src/meshkee/meshkee.client.ts b/src/meshkee/meshkee.client.ts new file mode 100644 index 0000000..4d72a03 --- /dev/null +++ b/src/meshkee/meshkee.client.ts @@ -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('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('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 { + const cellNumber = this.config.get('MESHKEE_SUPERADMIN_CELL')?.trim(); + const password = this.config + .get('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 { + 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 { + if (authorization?.trim()) return authorization.trim(); + const token = await this.resolveServiceToken(); + return token ? `Bearer ${token}` : null; + } + + private async serviceAuth(): Promise { + 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 { + 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; + }; + }; + 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 { + 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 { + let auth = await this.authHeader(authorization); + if (!auth) { + this.logger.warn( + `Skipping Meshkee update for business ${meshkeeBusinessId}: no auth token`, + ); + return; + } + + const payload: Record = {}; + 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})`, + ); + } + } +} diff --git a/src/meshkee/meshkee.module.ts b/src/meshkee/meshkee.module.ts new file mode 100644 index 0000000..9d4756c --- /dev/null +++ b/src/meshkee/meshkee.module.ts @@ -0,0 +1,8 @@ +import { Module } from '@nestjs/common'; +import { MeshkeeClient } from './meshkee.client'; + +@Module({ + providers: [MeshkeeClient], + exports: [MeshkeeClient], +}) +export class MeshkeeModule {} diff --git a/src/prisma/prisma.module.ts b/src/prisma/prisma.module.ts new file mode 100644 index 0000000..7207426 --- /dev/null +++ b/src/prisma/prisma.module.ts @@ -0,0 +1,9 @@ +import { Global, Module } from '@nestjs/common'; +import { PrismaService } from './prisma.service'; + +@Global() +@Module({ + providers: [PrismaService], + exports: [PrismaService], +}) +export class PrismaModule {} diff --git a/src/prisma/prisma.service.ts b/src/prisma/prisma.service.ts new file mode 100644 index 0000000..ba00c9f --- /dev/null +++ b/src/prisma/prisma.service.ts @@ -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(); + } +} diff --git a/src/website/dto/special-businesses.dto.ts b/src/website/dto/special-businesses.dto.ts new file mode 100644 index 0000000..ac36e43 --- /dev/null +++ b/src/website/dto/special-businesses.dto.ts @@ -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[]; +} diff --git a/src/website/dto/website-blog.dto.ts b/src/website/dto/website-blog.dto.ts new file mode 100644 index 0000000..e7f8d98 --- /dev/null +++ b/src/website/dto/website-blog.dto.ts @@ -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[]; +} diff --git a/src/website/dto/website-home-media.dto.ts b/src/website/dto/website-home-media.dto.ts new file mode 100644 index 0000000..8faa3d4 --- /dev/null +++ b/src/website/dto/website-home-media.dto.ts @@ -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[]; +} diff --git a/src/website/website-blog.controller.ts b/src/website/website-blog.controller.ts new file mode 100644 index 0000000..b4325ab --- /dev/null +++ b/src/website/website-blog.controller.ts @@ -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); + } +} diff --git a/src/website/website-blog.service.ts b/src/website/website-blog.service.ts new file mode 100644 index 0000000..85fc201 --- /dev/null +++ b/src/website/website-blog.service.ts @@ -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(); + 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(); + 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(); + 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(); + 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; + } +} diff --git a/src/website/website-special.defaults.ts b/src/website/website-special.defaults.ts new file mode 100644 index 0000000..692a164 --- /dev/null +++ b/src/website/website-special.defaults.ts @@ -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: [], + }, + ], + }; +} diff --git a/src/website/website.controller.ts b/src/website/website.controller.ts new file mode 100644 index 0000000..025ccd1 --- /dev/null +++ b/src/website/website.controller.ts @@ -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); + } +} diff --git a/src/website/website.defaults.ts b/src/website/website.defaults.ts new file mode 100644 index 0000000..6f92928 --- /dev/null +++ b/src/website/website.defaults.ts @@ -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 { + 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', + }), + ], + }; +} diff --git a/src/website/website.module.ts b/src/website/website.module.ts new file mode 100644 index 0000000..04acd1d --- /dev/null +++ b/src/website/website.module.ts @@ -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 {} diff --git a/src/website/website.service.ts b/src/website/website.service.ts new file mode 100644 index 0000000..1a6b25c --- /dev/null +++ b/src/website/website.service.ts @@ -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) { + 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(); + 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 { + 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 { + 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 { + 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 { + 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 { + 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(); + 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; + + 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 | 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; + 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 | 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)], + }; + } +} diff --git a/tsconfig.build.json b/tsconfig.build.json new file mode 100644 index 0000000..2fe1df2 --- /dev/null +++ b/tsconfig.build.json @@ -0,0 +1,4 @@ +{ + "extends": "./tsconfig.json", + "exclude": ["node_modules", "dist", "test", "**/*spec.ts"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..639a82b --- /dev/null +++ b/tsconfig.json @@ -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"] +}