diff --git a/deploy/DEPLOY.md b/deploy/DEPLOY.md index a791785..3b0d823 100644 --- a/deploy/DEPLOY.md +++ b/deploy/DEPLOY.md @@ -103,7 +103,8 @@ sudo certbot --nginx -d adm.montzar.ir 1. Open `https://adm.montzar.ir` → login 2. `https://adm.montzar.ir/get_user?domain=montpar.ir` (or any known domain) -3. Confirm uploads/logo URLs use `PUBLIC_API_URL` +3. `https://adm.montzar.ir/get_settings?domain=k.montzar.ir` (nginx must proxy `/get_settings` like `/get_user`) +4. Confirm uploads/logo URLs use `PUBLIC_API_URL` ## 8) Migrations with logical replication (failover replicas) diff --git a/deploy/nginx-adm.montzar.ir.conf b/deploy/nginx-adm.montzar.ir.conf index 491a437..22b7cb8 100644 --- a/deploy/nginx-adm.montzar.ir.conf +++ b/deploy/nginx-adm.montzar.ir.conf @@ -1,7 +1,7 @@ # Place under /etc/nginx/sites-available/adm.montzar.ir # SSL: assume Certbot already issued for adm.montzar.ir (or enable after first HTTP). # -# Single domain: SPA at / + Nest API under /api + public get_user/get_version + uploads. +# Single domain: SPA at / + Nest API under /api + public get_user/get_settings/get_version + uploads. upstream superadmin_api { server 127.0.0.1:3010; @@ -49,6 +49,15 @@ server { proxy_set_header X-Forwarded-Proto $scheme; } + location = /get_settings { + proxy_pass http://superadmin_api; + proxy_http_version 1.1; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + location = /get_version { proxy_pass http://superadmin_api; proxy_http_version 1.1; diff --git a/package.json b/package.json index ee3431b..1c476fc 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,8 @@ "prisma:copy-sms-between-tenants": "ts-node --transpile-only prisma/copy-sms-between-tenants.ts", "prisma:list-tenants": "ts-node --transpile-only prisma/list-tenants.ts", "replicas:sync": "bash deploy/sync-replica-schema.sh", - "replicas:check": "bash deploy/sync-replica-schema.sh --check" + "replicas:check": "bash deploy/sync-replica-schema.sh --check", + "settings:seed-templates": "echo 'Templates seed on first GET /api/setting-templates or POST /api/setting-templates/seed'" }, "prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" diff --git a/prisma/migrations/20260817190000_settings_tables/migration.sql b/prisma/migrations/20260817190000_settings_tables/migration.sql new file mode 100644 index 0000000..2b9e298 --- /dev/null +++ b/prisma/migrations/20260817190000_settings_tables/migration.sql @@ -0,0 +1,50 @@ +-- Drop legacy JSON snapshot on tenants +ALTER TABLE "tenants" DROP COLUMN IF EXISTS "setting"; + +-- Template defaults (copied onto new tenants) +CREATE TABLE "setting_templates" ( + "id" TEXT NOT NULL, + "key" VARCHAR(50) NOT NULL, + "title_fa" VARCHAR(255), + "can_edit" SMALLINT DEFAULT 0, + "value" TEXT, + "description" TEXT, + "type" VARCHAR(50), + "type_data" VARCHAR(255), + "is_exist" BOOLEAN NOT NULL DEFAULT true, + "is_active" SMALLINT DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "setting_templates_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "setting_templates_key_key" ON "setting_templates"("key"); + +-- Per-tenant settings (cascade on tenant delete) +CREATE TABLE "settings" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "key" VARCHAR(50) NOT NULL, + "title_fa" VARCHAR(255), + "can_edit" SMALLINT DEFAULT 0, + "value" TEXT, + "description" TEXT, + "type" VARCHAR(50), + "type_data" VARCHAR(255), + "is_exist" BOOLEAN NOT NULL DEFAULT true, + "is_active" SMALLINT DEFAULT 1, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "settings_pkey" PRIMARY KEY ("id") +); + +CREATE UNIQUE INDEX "settings_tenant_id_key_key" ON "settings"("tenant_id", "key"); +CREATE INDEX "settings_key_idx" ON "settings"("key"); +CREATE INDEX "settings_tenant_id_idx" ON "settings"("tenant_id"); + +ALTER TABLE "settings" ADD CONSTRAINT "settings_tenant_id_fkey" + FOREIGN KEY ("tenant_id") REFERENCES "tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f6c1e25..7737ca0 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -69,8 +69,6 @@ model Tenant { /// Legacy cache from older sync attempts — SuperAdmin SMS account is now source of truth smsSettingsCache Json? @map("sms_settings_cache") smsSyncedAt DateTime? @map("sms_synced_at") - /// Khanegi deploy settings snapshot (can_edit=0) imported via admin login + get_settings. - setting Json? /// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now. appUiConfig Json? @map("app_ui_config") /// Khanegi only: which appearance editor / get_user payload shape to use. @@ -90,12 +88,58 @@ model Tenant { serverAccounts ServerAccount[] pluginSubscriptions PluginSubscription[] supportSubscriptions SupportSubscription[] + settings Setting[] @@index([product]) @@index([isActive]) @@map("tenants") } +/// قالب پیش‌فرض تنظیمات (از deploy_settings_const). نسخهٔ جدید از اینجا کپی می‌شود. +model SettingTemplate { + id String @id @default(cuid()) + key String @unique @db.VarChar(50) + titleFa String? @map("title_fa") @db.VarChar(255) + canEdit Int? @default(0) @map("can_edit") @db.SmallInt + value String? @db.Text + description String? @db.Text + type String? @db.VarChar(50) + /// ستون همکار — برای سازگاری آینده + typeData String? @map("type_data") @db.VarChar(255) + isExist Boolean @default(true) @map("is_exist") + isActive Int? @default(1) @map("is_active") @db.SmallInt + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@map("setting_templates") +} + +/// تنظیمات هر نسخه — مثل جدول settings خانگی + tenant_id +model Setting { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + key String @db.VarChar(50) + titleFa String? @map("title_fa") @db.VarChar(255) + canEdit Int? @default(0) @map("can_edit") @db.SmallInt + value String? @db.Text + description String? @db.Text + type String? @db.VarChar(50) + /// ستون همکار — برای سازگاری آینده + typeData String? @map("type_data") @db.VarChar(255) + isExist Boolean @default(true) @map("is_exist") + isActive Int? @default(1) @map("is_active") @db.SmallInt + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + @@unique([tenantId, key]) + @@index([key]) + @@index([tenantId]) + @@map("settings") +} + /// SuperAdmin-owned SMS wallet + gateway config per sold version. /// Does NOT replace Hamkar/Khanegi internal SMS panels yet. model SmsAccount { diff --git a/prisma/setting-templates-from-deploy.json b/prisma/setting-templates-from-deploy.json new file mode 100644 index 0000000..3c2be05 --- /dev/null +++ b/prisma/setting-templates-from-deploy.json @@ -0,0 +1,1684 @@ +[ + { + "key": "name_fa", + "value": "", + "title_fa": "نام فارسی اپ", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "app_url", + "value": "", + "title_fa": "آدرس اپ (کاربر)", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "admin_url", + "value": "", + "title_fa": "آدرس پنل ادمین", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "logo_login", + "value": "", + "title_fa": "لوگوی صفحه ورود", + "can_edit": 0, + "is_active": 0, + "description": null, + "type": "text" + }, + { + "key": "logo_register", + "value": "", + "title_fa": "لوگوی ثبت‌نام", + "can_edit": 0, + "is_active": 0, + "description": null, + "type": "text" + }, + { + "key": "has_page_earn_money", + "value": "false", + "title_fa": "صفحه کسب درآمد", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "switch" + }, + { + "key": "montzar_order_response", + "value": "[]", + "title_fa": "پاسخ سفارش همکار", + "can_edit": 0, + "is_active": 1, + "description": null, + "type": "longtext" + }, + { + "key": "montzar_last_product_log", + "value": "{}", + "title_fa": "لاگ محصول همکار", + "can_edit": 0, + "is_active": 1, + "description": null, + "type": "longtext" + }, + { + "key": "chart_use_local_data", + "value": "false", + "title_fa": "نمودار از داده خود سایت", + "can_edit": 1, + "is_active": 1, + "description": "خاموش = Zaarbox (chart_id) | روشن = تاریخچه ذخیره‌شده prices", + "type": "switch" + }, + { + "key": "chart_id", + "value": "", + "title_fa": "UUID نمودار Zaarbox", + "can_edit": 1, + "is_active": 1, + "description": "خالی = UUID پیش‌فرض سرویس", + "type": "text" + }, + { + "key": "show_last_price", + "value": "true", + "title_fa": "نمایش آخرین قیمت DB اگر API قطع شد", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "show_gold_ounce_price", + "value": "false", + "title_fa": "نمایش قیمت انس طلا", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "show_home_price_when_closed", + "value": "false", + "title_fa": "نمایش قیمت صفحه اصلی خارج از ساعت کاری", + "can_edit": 1, + "is_active": 1, + "description": "روشن: وقتی فروشگاه خارج از ساعت کاری است، قیمت صفحه اصلی اپ همچنان نمایش داده می‌شود و پیام «فروشگاه بسته است» نشان داده نمی‌شود. خرید/فروش همچنان بسته می‌ماند. خاموش: مثل قبل، خارج از ساعت پیام بسته بودن و قیمت صفر نشان داده می‌شود.", + "type": "switch" + }, + { + "key": "show_product_details", + "value": "true", + "title_fa": "نمایش جزئیات اطلاعات محصول", + "can_edit": 1, + "is_active": 1, + "description": "روشن: بعد از انتخاب محصول در صفحه خرید/فروش، متن توضیح محصول نمایش داده می‌شود. خاموش: توضیح نشان داده نمی‌شود.", + "type": "switch" + }, + { + "key": "products_site_link_enabled", + "value": "false", + "title_fa": "دکمه لینک خارجی در خرید/فروش", + "can_edit": 1, + "is_active": 1, + "description": "روشن: در صفحه خرید/فروش (بالای تب دسته‌ها) یک دکمه تمام‌عرض نشان داده می‌شود که با کلیک، لینک تنظیم‌شده را در تب جدید مرورگر باز می‌کند. خاموش: دکمه مخفی است.", + "type": "switch" + }, + { + "key": "products_site_link_url", + "value": "", + "title_fa": "آدرس لینک دکمه خرید/فروش", + "can_edit": 1, + "is_active": 1, + "description": "آدرس کامل سایت یا صفحه خارجی (مثلاً https://example.com). با کلیک روی دکمه در اپ کاربر، همین آدرس در تب جدید مرورگر باز می‌شود. اگر خالی باشد، با وجود روشن بودن سوییچ، دکمه کاری انجام نمی‌دهد.", + "type": "text" + }, + { + "key": "products_site_link_text", + "value": "", + "title_fa": "متن دکمه لینک خرید/فروش", + "can_edit": 1, + "is_active": 1, + "description": "متنی که روی دکمه لینک خارجی در صفحه خرید/فروش نمایش داده می‌شود. اگر خالی باشد، پیش‌فرض «ورود به سایت» همراه آیکون کره زمین نشان داده می‌شود.", + "type": "text" + }, + { + "key": "show_product_trade_status", + "value": "false", + "title_fa": "نمایش خرید/فروش باز روی کارت محصول", + "can_edit": 1, + "is_active": 1, + "description": "روشن: روی کارت محصول برچسب خرید باز/بسته و فروش باز/بسته نمایش داده می‌شود. خاموش: مخفی است.", + "type": "switch" + }, + { + "key": "show_product_card_meta", + "value": "true", + "title_fa": "نمایش دسته و تحویل و مقدار روی کارت محصول", + "can_edit": 1, + "is_active": 1, + "description": "روشن: روی کارت محصول دسته‌بندی، وضعیت تحویل فیزیکی و مقدار/وزن نمایش داده می‌شود. خاموش: مخفی است.", + "type": "switch" + }, + { + "key": "main_product_price", + "value": "false", + "title_fa": "قیمت از محصول (legacy)", + "can_edit": 1, + "is_active": 1, + "description": "از price_provider استفاده کنید" + }, + { + "key": "montzar_mobile", + "value": "", + "title_fa": "موبایل API همکار", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "montzar_password", + "value": "", + "title_fa": "رمز API همکار", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "montzar_product_id", + "value": "25", + "title_fa": "کد محصول همکار", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "montzar_base_url", + "value": "https://limangold.ir", + "title_fa": "آدرس سرور همکار", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "montzar_token", + "value": "", + "title_fa": "توکن همکار (خودکار)", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "montzar_open_buy", + "value": "true", + "title_fa": "باز بودن خرید همکار", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "montzar_open_sell", + "value": "true", + "title_fa": "باز بودن فروش همکار", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "montzar_diff", + "value": "0", + "title_fa": "اختلاف قیمت همکار", + "can_edit": 0, + "is_active": 1, + "description": "سیستم پر می‌کند" + }, + { + "key": "montzar_live_price", + "value": "0", + "title_fa": "قیمت لحظه‌ای همکار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "montzar_mazaneh", + "value": "0", + "title_fa": "مظنه همکار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "montzar_truly_buy_mazaneh", + "value": "0", + "title_fa": "مظنه خرید همکار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "montzar_truly_sell_mazaneh", + "value": "0", + "title_fa": "مظنه فروش همکار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "node_client_id", + "value": "", + "title_fa": "شناسه کلاینت API خارجی (X-Client-Id)", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "node_shared_secret", + "value": "", + "title_fa": "کلید امضای HMAC API خارجی", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "external_order_api_enabled", + "value": "true", + "title_fa": "فعال‌سازی API سفارش خارجی (get_wallet / order)", + "can_edit": 1, + "is_active": 1, + "description": "true + node_client_id + node_shared_secret" + }, + { + "key": "wss_enabled", + "value": "true", + "title_fa": "قیمت لحظه‌ای (SSE)", + "can_edit": 0, + "is_active": 1, + "description": "روشن: اپ به sse_path وصل می‌شود؛ خاموش: polling /api/price/last و /customer/product/list", + "type": "switch" + }, + { + "key": "sse_path", + "value": "/api/price/stream", + "title_fa": "مسیر SSE قیمت", + "can_edit": 0, + "is_active": 1, + "description": "GET لاراول — استریم event:last و event:list با payload کامل", + "type": "text" + }, + { + "key": "tracking_systems", + "value": "[]", + "title_fa": "سایت‌های ری‌گیری", + "can_edit": 1, + "is_active": 1, + "description": "JSON لیست سایت‌های ری‌گیری — هر مورد: id، name، url. از منوی «سایت‌های ری‌گیری» ویرایش می‌شود.", + "type": "longtext" + }, + { + "key": "tracking_systems_enabled", + "value": "false", + "title_fa": "نمایش سایت‌های ری‌گیری", + "can_edit": 1, + "is_active": 1, + "description": "روشن: منوی «سایت‌های ری‌گیری» در ادمین و اپ مشتری نمایش داده می‌شود. خاموش: مخفی است.", + "type": "switch" + }, + { + "key": "montify_name", + "value": "", + "title_fa": "نام فروشگاه در درگاه", + "can_edit": 0, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "is_store", + "value": "false", + "title_fa": "حالت فروشگاهی", + "can_edit": 0, + "is_active": 1, + "description": "فعال = سبد خرید، دسته‌بندی، حواله، تخفیف", + "type": "switch" + }, + { + "key": "jibit_verification", + "value": "true", + "title_fa": "تأیید کارت بانکی با جیبیت", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "register_jibit_verify", + "value": "true", + "title_fa": "تأیید کدملی/موبایل در ثبت‌نام", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "close_price", + "value": "false", + "title_fa": "بستن قیمت در بازه زمانی", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "startDay", + "value": "09:00", + "title_fa": "شروع بازه معامله", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "endDay", + "value": "22:00", + "title_fa": "پایان بازه معامله", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "auto_active_customer", + "value": "false", + "title_fa": "فعال‌سازی خودکار کاربر", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "fee_free", + "value": "0", + "title_fa": "تعداد سفارش اول بدون کارمزد", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "charge_notif", + "value": "true", + "title_fa": "پیامک شارژ ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "customer_direct_sms", + "value": "false", + "title_fa": "پیامک تکی به مشتری از لیست", + "can_edit": 0, + "is_active": 1, + "description": "روشن: در لیست مشتریان دکمه ارسال پیامک تکی نمایش داده می‌شود. خاموش: مخفی است. فقط از صفحه دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "enable_conditional_order", + "value": "false", + "title_fa": "سفارش شرطی", + "can_edit": 0, + "is_active": 1, + "description": "روشن: قابلیت سفارش شرطی سراسری فعال است. خاموش: منو و ارزیابی اجرا نمی‌شود. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "enable_product_funds", + "value": "false", + "title_fa": "قلک محصول", + "can_edit": 0, + "is_active": 1, + "description": "روشن: ساخت و مدیریت قلک per-product در اپ مشتری فعال است (یک محصول در هر قلک؛ انتقال بدون سند حسابداری). خاموش: منو و API ساخت قلک جدید در دسترس نیست. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "has_page_gift_card", + "value": "false", + "title_fa": "کارت هدیه", + "can_edit": 0, + "is_active": 1, + "description": "روشن: منوی «ثبت کارت هدیه» و صفحه ثبت کد در اپ کاربر فعال است. خاموش: منو و صفحه مخفی است و API استفاده از کد هدیه در دسترس نیست. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "enable_customer_level_profit", + "value": "false", + "title_fa": "سود چندسطحی معرف", + "can_edit": 0, + "is_active": 1, + "description": "روشن: در لیست مشتریان آیکون تنظیم درصد سود هر سطح زیرمجموعه فعال است و پس از سفارش، سهم کارمزد طبق درصدهای هر معرف به کیف طلا واریز می‌شود. خاموش: آیکون و واریز سود چندسطحی غیرفعال است. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "has_page_price_trigger", + "value": "true", + "title_fa": "هشدار قیمت", + "can_edit": 0, + "is_active": 1, + "description": "روشن: منوی هشدار قیمت در اپ کاربر نمایش داده می‌شود. خاموش: منو و صفحه مخفی است. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "has_page_notification", + "value": "true", + "title_fa": "اعلان داخل اپ", + "can_edit": 0, + "is_active": 1, + "description": "روشن: زنگوله اعلان در اپ کاربر و منوی ثبت اعلان در ادمین فعال است. خاموش: مخفی است. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "jibit_enabled", + "value": "true", + "title_fa": "احراز هویت جیبیت", + "can_edit": 0, + "is_active": 1, + "description": "روشن: سرویس احراز هویت جیبیت (تطبیق کد ملی/موبایل و کارت بانکی) فعال است. خاموش: هیچ فراخوانی به جیبیت انجام نمی‌شود و احراز هویت خودکار کار نمی‌کند. فقط از صفحه دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "fee_conditional_order", + "value": "0", + "title_fa": "کارمزد سفارش شرطی", + "can_edit": 1, + "is_active": 1, + "description": "مبلغ ریالی که با هر بار ثبت تریگر از کیف پول ریالی مشتری کم می‌شود. صفر = رایگان.", + "type": "number" + }, + { + "key": "sms_notif_order_admin", + "value": "true", + "title_fa": "پیامک خرید/فروش به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_order_customer", + "value": "true", + "title_fa": "پیامک خرید/فروش به مشتری", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_withdraw_admin", + "value": "true", + "title_fa": "پیامک درخواست برداشت به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_withdraw_otp", + "value": "true", + "title_fa": "پیامک رمز برداشت", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_login_otp", + "value": "true", + "title_fa": "پیامک رمز ورود", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_gateway_charge_admin", + "value": "true", + "title_fa": "پیامک شارژ درگاه به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_register_customer", + "value": "true", + "title_fa": "پیامک خوش‌آمد ثبت‌نام", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_register_admin", + "value": "true", + "title_fa": "پیامک ثبت‌نام به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_jibit_admin", + "value": "true", + "title_fa": "پیامک احراز هویت به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_bank_card_admin", + "value": "true", + "title_fa": "پیامک کارت بانکی به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_bank_card_customer", + "value": "true", + "title_fa": "پیامک تایید کارت به مشتری", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_fund_withdraw_admin", + "value": "true", + "title_fa": "پیامک برداشت قلک به ادمین", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_fund_withdraw_customer", + "value": "true", + "title_fa": "پیامک برداشت قلک به مشتری", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_price_trigger", + "value": "true", + "title_fa": "پیامک هشدار قیمت", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_wallet_alert", + "value": "true", + "title_fa": "پیامک هشدار اعتبار پنل پیامک", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "sms_notif_support_admin", + "value": "true", + "title_fa": "پیامک پیام پشتیبانی به ادمین", + "can_edit": 1, + "is_active": 1, + "description": "روشن: وقتی مشتری اولین پیام خوانده‌نشده را در چت پشتیبانی می‌فرستد و ادمین همان اتاق را باز و visible ندارد، پیامک به ادمین‌های دارای «ارسال پیامک» می‌رود. خاموش: اعلان پیامک چت نمی‌رود." + }, + { + "key": "chat_webhook_secret", + "value": "", + "title_fa": "رمز وب‌هوک چت", + "can_edit": 0, + "is_active": 1, + "description": "رمز مشترک بین لاراول و سرور چت برای اعلان پیام پشتیبانی. فقط سرور؛ در صورت خالی بودن به‌صورت خودکار ساخته می‌شود.", + "type": "text" + }, + { + "key": "chat_webhook_base_url", + "value": "", + "title_fa": "آدرس پایه وب‌هوک چت", + "can_edit": 0, + "is_active": 1, + "description": "اگر APP_URL فرانت است (مثلاً https://niazitala.ir) اینجا آدرس لاراول را بگذار (مثلاً https://niazitala.ir/server). خالی = همان APP_URL.", + "type": "text" + }, + { + "key": "gift_for_referrer", + "value": "0.005", + "title_fa": "پاداش معرف", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "gift_for_referral", + "value": "0", + "title_fa": "پاداش معرف‌شونده", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "register_referral_sms_to_referrer", + "value": "false", + "title_fa": "SMS به معرف هنگام ثبت‌نام", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "register_referral_bonus_message", + "value": "false", + "title_fa": "پیام پاداش در SMS ثبت‌نام", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "referral_gift_on_first_order", + "value": "false", + "title_fa": "پاداش معرف پس از اولین سفارش", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "referral_gift_buy_from_montzar", + "value": "true", + "title_fa": "خرید پاداش معرفی از همکار", + "can_edit": 1, + "is_active": 1, + "description": "روشن: اگر منبع قیمت montzar باشد، قبل از واریز پاداش معرف/معرف‌شونده همان وزن از همکار خرید می‌شود؛ شکست خرید = واریز نمی‌شود. خاموش: مثل قبل فقط به کیف واریز می‌شود بدون خرید از همکار.", + "type": "switch" + }, + { + "key": "referral_limit", + "value": "", + "title_fa": "محدودیت دعوت", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "group_sms", + "value": "false", + "title_fa": "پیامک گروهی", + "can_edit": 0, + "is_active": 1, + "description": "روشن: دکمه ارسال پیامک گروهی در پنل ادمین فعال است (ارسال ۱۰تایی). خاموش: ارسال پیامک گروهی غیرفعال است. فقط از تنظیمات پیشرفته.", + "type": "switch" + }, + { + "key": "customer_special_pricing", + "value": "false", + "title_fa": "قیمت ویژه مشتری", + "can_edit": 0, + "is_active": 1, + "description": "روشن: زیرمنوی قیمت ویژه در مشتریان و اعمال سود خرید/فروش پکیج روی مظنه و سفارش برای کاربران انتخاب‌شده. خاموش: غیرفعال. فقط از دیپلوی قابل تغییر است.", + "type": "switch" + }, + { + "key": "sms_panel", + "value": "true", + "title_fa": "پنل پیامک (کیف پول)", + "can_edit": 0, + "is_active": 1, + "description": "روشن: پنل پیامک و کیف پول billing فعال است. خاموش: پیامک‌ها مثل قبل ارسال می‌شوند بدون billing.", + "type": "switch" + }, + { + "key": "tahesab_host", + "value": "", + "title_fa": "آدرس IP سرور ته‌حساب", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "tahesab_port", + "value": "", + "title_fa": "پورت سرور ته‌حساب", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "tahesab_token", + "value": "", + "title_fa": "توکن ته‌حساب", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "tahesab_db", + "value": "", + "title_fa": "دیتابیس ته‌حساب", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "tahesab_bank_account", + "value": "", + "title_fa": "حساب بانکی ته‌حساب", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "send_to_tahesab", + "value": "false", + "title_fa": "ارسال به ته‌حساب (قدیمی)", + "can_edit": 0, + "is_active": 0, + "description": null + }, + { + "key": "accounting_provider", + "value": "none", + "title_fa": "سیستم حسابداری", + "can_edit": 0, + "is_active": 1, + "description": "none,pargar,tahesab,zarin" + }, + { + "key": "zarin_api", + "value": "", + "title_fa": "آدرس API زرین", + "can_edit": 0, + "is_active": 1, + "description": "آدرس پایه وب‌سرویس زرین (بدون /ZarrinService/accounting).", + "type": "text" + }, + { + "key": "zarin_token", + "value": "", + "title_fa": "توکن زرین", + "can_edit": 0, + "is_active": 1, + "description": "توکن مکمل API زرین که در نرم‌افزار زرین تعریف می‌شود.", + "type": "text" + }, + { + "key": "zarin_noe_kar", + "value": "16", + "title_fa": "نوع کار پیش‌فرض زرین", + "can_edit": 0, + "is_active": 1, + "description": "کد noeKar هنگام ساخت کار ساخته در زرین. مقدار ۱۶ معمولاً گرمی است و برای sabtSanadTedadi مناسب نیست.", + "type": "number" + }, + { + "key": "zarin_noe_kar_tedadi", + "value": "17", + "title_fa": "نوع کار تعدادی زرین", + "can_edit": 0, + "is_active": 1, + "description": "noeKar برای سند تعدادی (سکه/شمش/گوی/مکعب). روی زرین معمولاً ۱۷=سکه-تعدادی یا ۱۸=اجرت تعدادی. اگر خالی/نامعتبر باشد کد از ۱۷ استفاده می‌کند.", + "type": "number" + }, + { + "key": "zarin_gorohe_kar", + "value": "52", + "title_fa": "گروه کار پیش‌فرض زرین", + "can_edit": 0, + "is_active": 1, + "description": "کد gorooheKar هنگام ساخت/جستجوی کار در زرین. برای جداسازی چند پنل روی یک زرین، برای هر پنل مقدار متفاوت بگذارید.", + "type": "number" + }, + { + "key": "zarin_name_g", + "value": "مشتری نقدی (خریدار)", + "title_fa": "نام گروه حساب مشتریان زرین", + "can_edit": 0, + "is_active": 1, + "description": "پارامتر nameG در addhesab — گروه حساب زرین. برای اپ خانگی: مشتری نقدی (خریدار) = گروه ۶. گروه همکار استفاده نشود (مظنه متفاوت است).", + "type": "text" + }, + { + "key": "zarin_uid_prefix", + "value": "", + "title_fa": "پیشوند عددی uid زرین", + "can_edit": 0, + "is_active": 1, + "description": "فقط عدد. وقتی چند پنل یک زرین دارند باید متفاوت باشد تا uid تکراری نشود.", + "type": "text" + }, + { + "key": "send_to_pargar", + "value": "false", + "title_fa": "ارسال به پرگار (قدیمی)", + "can_edit": 0, + "is_active": 0, + "description": null + }, + { + "key": "pargar_username", + "value": "", + "title_fa": "نام کاربری API پرگار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "pargar_password", + "value": "", + "title_fa": "رمز عبور API پرگار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "pargar_api_token", + "value": "", + "title_fa": "توکن ثابت API پرگار", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "pargar_item_id_rial", + "value": "0", + "title_fa": "کد کالای ریال پرگار", + "can_edit": 0, + "is_active": 1, + "description": "کد کالا برای اسناد واریز/برداشت ریالی در پرگار. صفر = استفاده از کد طلا با وزن صفر.", + "type": "number" + }, + { + "key": "pargar_item_id_gold", + "value": "11", + "title_fa": "کد کالای طلا پرگار", + "can_edit": 0, + "is_active": 1, + "description": "کد کالای طلا (کیف اصلی / آبشده) در پرگار برای اسناد وزن‌دار.", + "type": "number" + }, + { + "key": "telegram_bot_token", + "value": "", + "title_fa": "توکن بات تلگرام", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "bale_bot_token", + "value": "", + "title_fa": "توکن بات بله", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "rubika_bot_token", + "value": "", + "title_fa": "توکن بات روبیکا", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "price_channel_access", + "value": "false", + "title_fa": "دسترسی ارسال قیمت به کانال", + "can_edit": 0, + "is_active": 1, + "description": "روشن: گروه «ارسال قیمت به کانال» در تنظیمات عادی پنل دیده می‌شود. خاموش: مخفی است (فقط از تنظیمات پیشرفته).", + "type": "switch" + }, + { + "key": "price_channel_enabled", + "value": "false", + "title_fa": "ارسال قیمت به کانال", + "can_edit": 1, + "is_active": 1, + "description": "روشن: بعد از هر تغییر قیمت، مظنه به کانال‌های پیام‌رسان انتخاب‌شده ارسال می‌شود.", + "type": "switch" + }, + { + "key": "price_channel_messengers", + "value": "", + "title_fa": "پیام‌رسان‌های ارسال قیمت", + "can_edit": 1, + "is_active": 1, + "description": "کدام پیام‌رسان‌ها قیمت بگیرند؟ می‌توانید چند مورد را انتخاب کنید. گزینه‌ها: بله، تلگرام، روبیکا.", + "type": "multiselect" + }, + { + "key": "price_channel_diff", + "value": "0", + "title_fa": "اختلاف قیمت کانال (ریال)", + "can_edit": 1, + "is_active": 1, + "description": "مبلغی که به قیمت خرید کانال اضافه و از قیمت فروش کم می‌شود (اسپرد اضافه فقط برای کانال).", + "type": "price" + }, + { + "key": "price_channel_min_seconds", + "value": "120", + "title_fa": "حداقل فاصله ارسال (ثانیه)", + "can_edit": 1, + "is_active": 1, + "description": "حداقل فاصله بین دو ارسال به کانال؛ جلوی سیل پیام را می‌گیرد. صفر = هر تغییر قیمت.", + "type": "number" + }, + { + "key": "price_channel_message_text", + "value": "@price_channel_default_text", + "title_fa": "قالب متن پیام کانال", + "can_edit": 1, + "is_active": 1, + "description": "@price_channel_message_desc", + "type": "longtext" + }, + { + "key": "price_channel_footer_id", + "value": "", + "title_fa": "آیدی کانال در پیام", + "can_edit": 1, + "is_active": 1, + "description": "آیدی/یوزرنیم کانال برای متغیر %آیدی در قالب پیام. خالی = خط خالی.", + "type": "text" + }, + { + "key": "price_channel_footer_phone", + "value": "", + "title_fa": "شماره تماس در پیام", + "can_edit": 1, + "is_active": 1, + "description": "شماره تماس برای متغیر %شماره در قالب پیام. خالی = خط خالی.", + "type": "text" + }, + { + "key": "price_channel_footer_link", + "value": "", + "title_fa": "لینک در پیام", + "can_edit": 1, + "is_active": 1, + "description": "لینک برای متغیر %لینک در قالب پیام. خالی = خط خالی.", + "type": "text" + }, + { + "key": "price_channel_bale_to", + "value": "", + "title_fa": "مقصد ارسال بله", + "can_edit": 0, + "is_active": 1, + "description": "آیدی چنل/گروه بله برای ارسال قیمت (چند مقصد با ویرگول).", + "type": "text" + }, + { + "key": "price_channel_telegram_to", + "value": "", + "title_fa": "مقصد ارسال تلگرام", + "can_edit": 0, + "is_active": 1, + "description": "آیدی چنل/گروه تلگرام برای ارسال قیمت (چند مقصد با ویرگول).", + "type": "text" + }, + { + "key": "price_channel_rubika_to", + "value": "", + "title_fa": "مقصد ارسال روبیکا", + "can_edit": 0, + "is_active": 1, + "description": "آیدی چنل/گروه روبیکا برای ارسال قیمت (چند مقصد با ویرگول).", + "type": "text" + }, + { + "key": "change_mesghal", + "value": "4.3318", + "title_fa": "وزن مثقال (گرم)", + "can_edit": 1, + "is_active": 1, + "description": "ضریب تبدیل قیمت مثقال به گرم برای نمایش خط «هر گرم» در پیام کانال.", + "type": "number" + }, + { + "key": "support_messenger_id", + "value": "", + "title_fa": "شناسه پشتیبانی پیام‌رسان", + "can_edit": 1, + "is_active": 0, + "description": "یوزرنیم یا شماره تماس بسته به پیام‌رسان انتخاب‌شده" + }, + { + "key": "support_messenger_text", + "value": "", + "title_fa": "متن پشتیبانی پیام‌رسان", + "can_edit": 1, + "is_active": 0, + "description": "متن دکمه یا توضیح پشتیبانی در اپ کاربر" + }, + { + "key": "invite_link_text", + "value": "@invite_link_default_text", + "title_fa": "متن دعوتنامه اشتراک‌گذاری", + "can_edit": 1, + "is_active": 1, + "description": "@invite_link_desc", + "type": "longtext" + }, + { + "key": "support_messenger", + "value": "", + "title_fa": "پیام‌رسان پشتیبانی", + "can_edit": 1, + "is_active": 1, + "description": "bale,telegram,whatsapp,rubika,eitaa,soroush" + }, + { + "key": "show_to_users_chat", + "value": "deactivate", + "title_fa": "نمایش چت به کاربران", + "can_edit": 0, + "is_active": 1, + "description": "روشن: قابلیت چت پشتیبانی برای این دیپلوی فعال است (منوی ادمین و پیش‌نیاز نمایش به کاربر). خاموش: چت در ادمین و اپ کاربر مخفی است و سوییچ «چت پشتیبانی» (has_chat) از تنظیمات عادی هم برداشته می‌شود.", + "type": "switch" + }, + { + "key": "chat_api", + "value": "", + "title_fa": "API چت", + "can_edit": 0, + "is_active": 1, + "description": "هاست سرور چت جدا (بدون https)، مثلاً chat.example.ir — برای WS و ساخت روم.", + "type": "text" + }, + { + "key": "chat_api_key", + "value": "", + "title_fa": "کلید API چت", + "can_edit": 0, + "is_active": 1, + "description": "کلید API سرور چت جدا.", + "type": "text" + }, + { + "key": "chat_upload_url", + "value": "", + "title_fa": "آدرس آپلود فایل چت", + "can_edit": 0, + "is_active": 1, + "description": "URL کامل آپلود مدیا برای چت پشتیبانی.", + "type": "text" + }, + { + "key": "chat_download_url", + "value": "", + "title_fa": "آدرس دانلود فایل چت", + "can_edit": 0, + "is_active": 1, + "description": "پایهٔ URL دانلود/نمایش فایل‌های چت پشتیبانی.", + "type": "text" + }, + { + "key": "has_chat", + "value": "true", + "title_fa": "چت پشتیبانی", + "can_edit": 1, + "is_active": 1, + "description": "روشن: چت پشتیبانی در اپ مشتری نمایش داده می‌شود (فقط وقتی show_to_users_chat هم روشن باشد). خاموش: دکمه و صفحه چت برای کاربر مخفی است.", + "type": "switch" + }, + { + "key": "deposit_sms_support_number", + "value": "", + "title_fa": "شماره تماس پشتیبانی", + "can_edit": 1, + "is_active": 1, + "description": "شماره تماس پشتیبانی که در صفحه واریز، درباره ما و قوانین به مشتری نشان داده می‌شود. با کلیک، تماس تلفنی برقرار می‌شود.", + "type": "number" + }, + { + "key": "deposit_account_holder", + "value": "", + "title_fa": "نام صاحب حساب واریز", + "can_edit": 1, + "is_active": 1, + "description": "نام صاحب حساب واریز — در متن راهنما و پیام پشتیبانی با ٪نام یا %نام جایگزین می‌شود.", + "type": "text" + }, + { + "key": "deposit_card_number", + "value": "", + "title_fa": "شماره کارت واریز", + "can_edit": 1, + "is_active": 1, + "description": "شماره کارت واریز — در متن‌ها با ٪کارت یا %کارت جایگزین می‌شود.", + "type": "text" + }, + { + "key": "deposit_account_number", + "value": "", + "title_fa": "شماره حساب واریز", + "can_edit": 1, + "is_active": 1, + "description": "شماره حساب بانکی واریز — در مودال راهنمای شارژ کنار کارت و شبا نمایش داده می‌شود. در متن‌ها با ٪حساب یا %حساب جایگزین می‌شود.", + "type": "text" + }, + { + "key": "deposit_sheba", + "value": "", + "title_fa": "شبا واریز", + "can_edit": 1, + "is_active": 1, + "description": "شماره شبا واریز — در متن‌ها با ٪شبا یا %شبا جایگزین می‌شود.", + "type": "text" + }, + { + "key": "deposit_method_card_to_card", + "value": "true", + "title_fa": "فعال‌سازی کارت به کارت", + "can_edit": 1, + "is_active": 1, + "description": "روشن (پیش‌فرض): روش کارت به کارت در صفحه واریز اپ نمایش داده می‌شود. خاموش: این روش از لیست روش‌های واریز حذف می‌شود.", + "type": "switch" + }, + { + "key": "deposit_method_gateway", + "value": "false", + "title_fa": "فعال‌سازی درگاه پرداخت", + "can_edit": 1, + "is_active": 1, + "description": "روشن: روش درگاه پرداخت در صفحه واریز نمایش داده می‌شود. خاموش: مخفی است. اتصال و کدهای درگاه در تنظیمات پیشرفته → روش‌های واریز تنظیم می‌شوند.", + "type": "switch" + }, + { + "key": "deposit_method_identified", + "value": "false", + "title_fa": "فعال‌سازی شناسه واریز", + "can_edit": 1, + "is_active": 1, + "description": "روشن: روش شناسه واریز در صفحه واریز نمایش داده می‌شود. خاموش: مخفی است.", + "type": "switch" + }, + { + "key": "deposit_method_shetab", + "value": "false", + "title_fa": "فعال‌سازی شبکه شتابی", + "can_edit": 1, + "is_active": 1, + "description": "روشن: روش شبکه شتابی در صفحه واریز نمایش داده می‌شود. خاموش: مخفی است.", + "type": "switch" + }, + { + "key": "deposit_support_message_text", + "value": "شماره حساب", + "title_fa": "متن پیام به پشتیبانی (واریز)", + "can_edit": 1, + "is_active": 1, + "description": "@deposit_support_msg_desc", + "type": "longtext" + }, + { + "key": "up_price", + "value": "0", + "title_fa": "بیشترین قیمت امروز", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "down_price", + "value": "0", + "title_fa": "کمترین قیمت امروز", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "price_extreme_date", + "value": "", + "title_fa": "تاریخ min/max روز", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "metal_type", + "value": "طلای آبشده", + "title_fa": "عنوان فلز در UI", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "main_metal", + "value": "طلا", + "title_fa": "نام فلز اصلی (ادمین)", + "can_edit": 0, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "external_payment_enabled", + "value": "false", + "title_fa": "فعال‌سازی شارژ آنلاین", + "can_edit": 0, + "is_active": 1, + "description": "روشن: در صورت کامل بودن BASE_URL و client_id و client_secret و gateway، دکمه پرداخت آنلاین در اپ نمایش داده می‌شود. خاموش: فقط واریز دستی.", + "type": "switch" + }, + { + "key": "wallet_payment_provider", + "value": "none", + "title_fa": "نوع درگاه شارژ آنلاین", + "can_edit": 0, + "is_active": 1, + "description": "none=خاموش، montify=پارتنر Montify|none,montify", + "type": "select" + }, + { + "key": "external_payment_base_url", + "value": "", + "title_fa": "آدرس API درگاه (BASE_URL)", + "can_edit": 0, + "is_active": 1, + "description": "آدرس پایه API سرویس Montify (بدون اسلش انتهایی)، مثلاً https://node.example.com — فقط سرور؛ در اپ کاربر ارسال نمی‌شود.", + "type": "text" + }, + { + "key": "external_payment_client_id", + "value": "", + "title_fa": "شناسه کلاینت (client_id)", + "can_edit": 0, + "is_active": 1, + "description": "شناسه partner (client_id) — فقط سرور؛ در اپ کاربر یا پاسخ get_settings عمومی نیست.", + "type": "text" + }, + { + "key": "external_payment_client_secret", + "value": "", + "title_fa": "رمز کلاینت (client_secret)", + "can_edit": 0, + "is_active": 1, + "description": "رمز مخفی partner (client_secret) — فقط سرور. در فرانت و Network تب کاربر دیده نمی‌شود.", + "type": "text" + }, + { + "key": "external_payment_gateway", + "value": "zarinpall", + "title_fa": "نام درگاه داخل Montify", + "can_edit": 0, + "is_active": 1, + "description": "نام درگاه فعال داخل Montify: zarinpall، zibal، payping یا sepal|zarinpall,zibal,payping,sepal", + "type": "select" + }, + { + "key": "external_payment_return_url", + "value": "", + "title_fa": "آدرس برگشت (return_url)", + "can_edit": 0, + "is_active": 1, + "description": "آدرس صفحه فرانت بعد از پرداخت. خالی = دامنه سایت + /wallet/payment-result", + "type": "text" + }, + { + "key": "external_payment_webhook_url", + "value": "", + "title_fa": "آدرس Webhook", + "can_edit": 0, + "is_active": 1, + "description": "آدرس بک‌اند برای webhook (شارژ قطعی کیف). خالی = مسیر پیش‌فرض سرور.", + "type": "text" + }, + { + "key": "payping_fin_sync_enabled", + "value": "false", + "title_fa": "همگام‌سازی fin", + "can_edit": 0, + "is_active": 1, + "description": null + }, + { + "key": "payping_fin_url", + "value": "https://limangold.ir/kernel/admin/fin", + "title_fa": "URL fin", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "payping_fin_token", + "value": "", + "title_fa": "توکن fin", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "payping_fin_customer_id", + "value": "23", + "title_fa": "customer_id fin", + "can_edit": 1, + "is_active": 1, + "description": null + }, + { + "key": "payping_start_url", + "value": "https://payping.limangold.ir/payment/start", + "title_fa": "آدرس شروع درگاه", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "payping_verify_url", + "value": "https://payping.limangold.ir/payment/verify", + "title_fa": "آدرس تأیید درگاه", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "payping_gateway_name", + "value": "", + "title_fa": "نام gateway درگاه", + "can_edit": 1, + "is_active": 1, + "description": "sepal,", + "type": "select" + }, + { + "key": "payping_gateway_items", + "value": "", + "title_fa": "لیست item (چرخشی)", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "payping_round_toman", + "value": "false", + "title_fa": "گرد کردن مبلغ به تومان", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "switch" + }, + { + "key": "payping_strict_failed_withdraw", + "value": "false", + "title_fa": "اولویت تراکنش ناموفق", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "switch" + }, + { + "key": "open_register", + "value": "true", + "title_fa": "ثبت‌نام باز", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "switch" + }, + { + "key": "referral_id", + "value": "required", + "title_fa": "کد معرف اجباری", + "can_edit": 1, + "is_active": 1, + "description": "روشن = اجباری در ثبت‌نام | خاموش = اختیاری", + "type": "switch" + }, + { + "key": "referral_show", + "value": "true", + "title_fa": "نمایش فیلد کد معرف", + "can_edit": 1, + "is_active": 1, + "description": "روشن: فیلد کد معرف در صفحه ثبت‌نام اپ کاربر نمایش داده می‌شود. خاموش: فیلد مخفی است (لینک دعوت با ?id= همچنان اعمال می‌شود).", + "type": "switch" + }, + { + "key": "buy_fee", + "value": "", + "title_fa": "سود خرید نمایش", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "price" + }, + { + "key": "sell_fee", + "value": "", + "title_fa": "سود فروش نمایش", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "price" + }, + { + "key": "fee_service_charge_percentage", + "value": "0.005", + "title_fa": "درصد کارمزد سفارش", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "number" + }, + { + "key": "fee_deduct_from_gold", + "value": "false", + "title_fa": "اعمال مبلغ دقیق در معامله مشتری", + "can_edit": 0, + "is_active": 1, + "description": "روشن: مشتری دقیقاً همان مبلغ یا وزنی را که وارد کرده دریافت می‌کند. در فروش، مثلاً ۱۰۰ میلیون ریال درخواست → همان ۱۰۰ میلیون واریز و کارمزد از طلای کیف کسر می‌شود. در خرید، مثلاً ۱۰ گرم درخواست → همان ۱۰ گرم واریز و کارمزد به ریال قابل پرداخت اضافه می‌شود. خاموش (پیش‌فرض): در فروش کارمزد از مبلغ ریالی واریزی کم می‌شود (مشتری مبلغ کمتری می‌گیرد). در خرید کارمزد از وزن طلای واریزی کم می‌شود (مشتری طلای کمتری می‌گیرد).", + "type": "switch" + }, + { + "key": "min_withdraw", + "value": "", + "title_fa": "حداقل برداشت (ریال)", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "price" + }, + { + "key": "min_deposit", + "value": "1000000", + "title_fa": "حداقل واریز کیف پول (ریال)", + "can_edit": 1, + "is_active": 1, + "description": "حداقل مبلغ واریز به کیف پول (ریال). مبالغ کمتر در اپ و درگاه پذیرفته نمی‌شوند.", + "type": "price" + }, + { + "key": "min_first_buy", + "value": "", + "title_fa": "حداقل اولین خرید (ریال)", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "price" + }, + { + "key": "minimum_sell", + "value": "", + "title_fa": "حداقل فروش", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "price" + }, + { + "key": "referral_bonus_amount", + "value": "", + "title_fa": "مقدار پاداش معرف", + "can_edit": 0, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "balance_report_exclude_customer_id", + "value": "", + "title_fa": "حذف مشتری از تراز", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "text" + }, + { + "key": "montzar_closed", + "value": "false", + "title_fa": "بسته بودن همکار", + "can_edit": 1, + "is_active": 1, + "description": null, + "type": "switch" + }, + { + "key": "price_provider", + "title_fa": "منبع قیمت", + "value": "montzar", + "can_edit": 1, + "is_exist": 0, + "is_active": 1, + "type": "select", + "description": "منبع فعال قیمت|montzar,zaarbox,main_product" + }, + { + "key": "service_charge_percentage", + "title_fa": "کارمزد درصدی (alias)", + "value": "fee_service_charge_percentage", + "can_edit": 1, + "is_exist": "0.005", + "is_active": 0, + "type": 0 + }, + { + "key": "title_pwa", + "title_fa": "عنوان PWA", + "value": "", + "can_edit": 0, + "is_exist": 0, + "is_active": 0, + "type": "text" + }, + { + "key": "title_fav", + "title_fa": "عنوان favicon", + "value": "", + "can_edit": 0, + "is_exist": 0, + "is_active": 0, + "type": "text" + }, + { + "key": "text_rule", + "title_fa": "متن قوانین", + "value": "", + "can_edit": 1, + "is_exist": 0, + "is_active": 1, + "type": "longtext" + }, + { + "key": "text_about", + "title_fa": "متن درباره ما", + "value": "", + "can_edit": 1, + "is_exist": 0, + "is_active": 1, + "type": "longtext" + }, + { + "key": "zaarbox_api_url", + "title_fa": "آدرس API تابلو Zaarbox", + "value": "", + "can_edit": 1, + "is_exist": 0, + "is_active": 1, + "type": "text" + }, + { + "key": "zaarbox_price_key", + "title_fa": "شناسه ردیف تابلو (key یا UUID)", + "value": "", + "can_edit": 1, + "is_exist": 0, + "is_active": 1, + "type": "text" + }, + { + "key": "main_product", + "title_fa": "محصول آبشده", + "value": "", + "can_edit": 1, + "is_exist": 0, + "is_active": 1, + "type": "text" + }, + { + "key": "profit_gold_api_enabled", + "title_fa": "API سود طلا", + "value": "false", + "can_edit": 1, + "is_exist": 0, + "is_active": 0, + "type": "switch" + }, + { + "key": "unit", + "value": "", + "title_fa": "واحد وزن دارایی و معاملات", + "can_edit": 1, + "is_active": 1, + "description": "واحد نمایش وزن فلز در دارایی‌ها، کیف پول و تراکنش‌ها|گرم,کیلوگرم", + "type": "select" + }, + { + "key": "main_wallet_products", + "value": "13", + "title_fa": "محصولات متصل به کیف اصلی", + "can_edit": 0, + "is_active": 1, + "description": "محصولاتی که موجودی‌شان از کیف طلای اصلی خوانده می‌شود. چند محصول با کاما. پیش‌فرض: 13" + }, + { + "key": "require_zero_main_gold_before_other_buy", + "value": "true", + "title_fa": "الزام صفر بودن کیف اصلی قبل از خرید محصول دیگر", + "can_edit": 1, + "is_active": 1, + "description": "روشن: اگر مانده طلای کیف اصلی صفر یا زیر صفر باشد (بدهی)، مشتری ابتدا باید آبشده/مانده کیف اصلی را صفر کند و بعد می‌تواند محصول دیگر بخرد. خاموش: بدون این محدودیت. معامله خودِ محصولات کیف اصلی همیشه مجاز است.", + "type": "switch" + }, + { + "key": "apk_download_link", + "value": "", + "title_fa": "لینک دانلود اپ اندروید", + "can_edit": 0, + "is_active": 1, + "description": "آدرس کامل فایل APK اندروید (https). اگر خالی باشد لینک دانلود در اپ نمایش داده نمی‌شود.", + "type": "text" + }, + { + "key": "banner_enabled", + "value": "true", + "title_fa": "نمایش بنرها", + "can_edit": 1, + "is_active": 1, + "description": "روشن: منوی «بنرها» در ادمین و اسلایدر صفحه اصلی اپ مشتری نمایش داده می‌شود. خاموش: مخفی است (محتوای بنر از همان منو مدیریت می‌شود).", + "type": "switch" + }, + { + "key": "permision__enabled", + "value": "false", + "title_fa": "نمایش منوی مجوزها", + "can_edit": 0, + "is_active": 1, + "description": "روشن: آیتم «مجوز ها» در منوی تنظیمات اپ خانگی (تم جدید) نمایش داده می‌شود. خاموش: مخفی است.", + "type": "switch" + } +] \ No newline at end of file diff --git a/src/program-settings/dto/import-program-settings.dto.ts b/src/program-settings/dto/import-program-settings.dto.ts index 4acea50..dbf7423 100644 --- a/src/program-settings/dto/import-program-settings.dto.ts +++ b/src/program-settings/dto/import-program-settings.dto.ts @@ -1,4 +1,15 @@ -import { IsString, MaxLength, MinLength } from 'class-validator'; +import { Allow, IsArray, IsIn, IsOptional, IsString, MaxLength, MinLength, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; + +export class ImportDecisionDto { + @IsString() + @MinLength(1) + @MaxLength(50) + key!: string; + + @IsIn(['none', 'this', 'all']) + scope!: 'none' | 'this' | 'all'; +} export class ImportProgramSettingsDto { @IsString() @@ -10,4 +21,15 @@ export class ImportProgramSettingsDto { @MinLength(1) @MaxLength(120) password!: string; + + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => ImportDecisionDto) + decisions?: ImportDecisionDto[]; + + /** اگر true فقط پیش‌نمایش برگردد */ + @IsOptional() + @Allow() + preview?: boolean; } diff --git a/src/program-settings/dto/setting-keys.dto.ts b/src/program-settings/dto/setting-keys.dto.ts new file mode 100644 index 0000000..c169aba --- /dev/null +++ b/src/program-settings/dto/setting-keys.dto.ts @@ -0,0 +1,14 @@ +import { Allow, IsArray, IsOptional, IsString, MaxLength, MinLength } from 'class-validator'; + +export class AddSettingKeysDto { + @IsArray() + @Allow() + items!: Array>; +} + +export class DeleteSettingKeyDto { + @IsString() + @MinLength(1) + @MaxLength(50) + key!: string; +} diff --git a/src/program-settings/program-settings.controller.ts b/src/program-settings/program-settings.controller.ts index 138a5d6..7f9664e 100644 --- a/src/program-settings/program-settings.controller.ts +++ b/src/program-settings/program-settings.controller.ts @@ -35,10 +35,14 @@ export class ProgramSettingsController { @Body() body: ImportProgramSettingsDto, @CurrentUser() user: { id: string }, ) { - return this.programSettings.importFromKhanegi( + if (body.preview) { + return this.programSettings.previewImport(tenantId, body.mobile, body.password); + } + return this.programSettings.applyImport( tenantId, body.mobile, body.password, + body.decisions || [], user.id, ); } diff --git a/src/program-settings/program-settings.module.ts b/src/program-settings/program-settings.module.ts index 3db1861..fd8c951 100644 --- a/src/program-settings/program-settings.module.ts +++ b/src/program-settings/program-settings.module.ts @@ -5,6 +5,7 @@ import { ProgramSettingsController } from './program-settings.controller'; import { PublicProgramSettingsController } from './public-program-settings.controller'; import { ApiPublicProgramSettingsController } from './api-public-program-settings.controller'; import { RemoteProgramSettingsController } from './remote-program-settings.controller'; +import { SettingTemplatesController } from './setting-templates.controller'; import { ProgramSettingsService } from './program-settings.service'; @Module({ @@ -14,6 +15,7 @@ import { ProgramSettingsService } from './program-settings.service'; PublicProgramSettingsController, ApiPublicProgramSettingsController, RemoteProgramSettingsController, + SettingTemplatesController, ], providers: [ProgramSettingsService], exports: [ProgramSettingsService], diff --git a/src/program-settings/program-settings.service.ts b/src/program-settings/program-settings.service.ts index c5ab35c..ca314de 100644 --- a/src/program-settings/program-settings.service.ts +++ b/src/program-settings/program-settings.service.ts @@ -4,73 +4,101 @@ import { ForbiddenException, HttpException, Injectable, + Logger, NotFoundException, + OnModuleInit, } from '@nestjs/common'; import axios from 'axios'; -import { Prisma, ProductType } from '@prisma/client'; +import { existsSync, readFileSync } from 'fs'; +import { join } from 'path'; +import { Prisma, ProductType, Setting, SettingTemplate } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { isSecretOrServerOnlySettingKey } from './secret-setting-keys'; import { isRuntimeProgramSettingKey } from './runtime-setting-keys'; export type ProgramSettingRow = { - id?: number; + id?: string | number; key?: string; value?: string | number | boolean | null; title_fa?: string | null; description?: string | null; type?: string | null; + type_data?: string | null; is_active?: number | boolean | string | null; can_edit?: number | boolean | string | null; + is_exist?: number | boolean | string | null; }; -export type ProgramSettingsPayload = { - importedAt: string; - source: 'khanegi_admin' | 'superadmin'; - apiBase: string; - items: ProgramSettingRow[]; +export type ImportDecisionScope = 'none' | 'this' | 'all'; + +export type ImportDecision = { + key: string; + scope: ImportDecisionScope; +}; + +type TemplateSeedRow = { + key: string; + value?: string | null; + title_fa?: string | null; + can_edit?: number | null; + is_active?: number | null; + description?: string | null; + type?: string | null; + type_data?: string | null; + is_exist?: boolean | number | null; }; @Injectable() -export class ProgramSettingsService { +export class ProgramSettingsService implements OnModuleInit { + private readonly logger = new Logger(ProgramSettingsService.name); + constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, ) {} + async onModuleInit() { + try { + const result = await this.ensureTemplatesSeeded(); + if (result.seeded) { + this.logger.log(`setting_templates seeded (${result.count} keys)`); + } + await this.seedEmptyKhanegiTenants(); + } catch (error: any) { + this.logger.warn(`setting_templates seed skipped: ${error?.message || error}`); + } + } + async get(tenantId: string) { const tenant = await this.requireKhanegiTenant(tenantId); - const stored = this.readStored(tenant.setting); + const items = await this.listTenantRows(tenantId); return { tenant: this.tenantMeta(tenant), - importedAt: stored?.importedAt ?? null, - source: stored?.source ?? null, - apiBase: stored?.apiBase ?? null, - items: this.omitRuntime(stored?.items ?? []), - count: this.omitRuntime(stored?.items ?? []).length, + items, + count: items.length, }; } async getPublicByHost(hostHeader?: string, domainQuery?: string) { const tenant = await this.findKhanegiTenantByHost(domainQuery || hostHeader || ''); - const stored = this.readStored(tenant.setting); - const items = this.omitRuntime(stored?.items ?? []).filter( + const items = (await this.listTenantRows(tenant.id)).filter( (row) => !isSecretOrServerOnlySettingKey(String(row.key || '')), ); return { settings: items, - updatedAt: stored?.importedAt || null, + updatedAt: null, count: items.length, }; } async getRemoteFull(tenantId: string) { const tenant = await this.requireKhanegiTenant(tenantId); - const stored = this.readStored(tenant.setting); + const items = await this.listTenantRows(tenant.id); return { - settings: this.omitRuntime(stored?.items ?? []), - updatedAt: stored?.importedAt || null, - count: this.omitRuntime(stored?.items ?? []).length, + settings: items, + updatedAt: null, + count: items.length, }; } @@ -81,46 +109,54 @@ export class ProgramSettingsService { ) { const tenant = await this.requireKhanegiTenant(tenantId); const incoming = this.asSettingsArray(itemsRaw) - .map((row) => ({ - ...row, - key: String(row.key || '').trim(), - value: row.value == null ? '' : String(row.value), - can_edit: 0 as const, - is_active: - row.is_active === 0 || row.is_active === false || row.is_active === '0' ? 0 : 1, - })) + .map((row) => this.normalizeIncoming(row)) .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)); if (!incoming.length) { throw new BadRequestException('هیچ تنظیم معتبری برای ذخیره ارسال نشده'); } - const previous = this.readStored(tenant.setting); - const byKey = new Map(); - for (const row of previous?.items ?? []) { - const key = String(row.key || '').trim(); - if (!key || isRuntimeProgramSettingKey(key)) continue; - byKey.set(key, row); - } - for (const row of incoming) { - const prev = byKey.get(row.key) || {}; - byKey.set(row.key, { ...prev, ...row }); - } - const items = [...byKey.values()]; - - const payload: ProgramSettingsPayload = { - importedAt: new Date().toISOString(), - source: 'superadmin', - apiBase: previous?.apiBase || '', - items, - }; - - await this.prisma.tenant.update({ - where: { id: tenantId }, - data: { - setting: payload as unknown as Prisma.InputJsonValue, - appVersion: this.bumpAppVersion(tenant.appVersion), - }, + await this.prisma.$transaction(async (tx: any) => { + for (const row of incoming) { + const existing = await tx.setting.findFirst({ + where: { tenantId, key: row.key, deletedAt: null }, + }); + if (existing) { + await tx.setting.update({ + where: { id: existing.id }, + data: { + value: row.value, + titleFa: row.title_fa ?? existing.titleFa, + description: row.description ?? existing.description, + type: row.type ?? existing.type, + typeData: row.type_data ?? existing.typeData, + isActive: this.toSmallInt(row.is_active, existing.isActive ?? 1), + canEdit: 0, + isExist: true, + deletedAt: null, + }, + }); + } else { + await tx.setting.create({ + data: { + tenantId, + key: row.key, + value: row.value, + titleFa: row.title_fa || row.key, + description: row.description || '', + type: row.type || '', + typeData: row.type_data || null, + isActive: this.toSmallInt(row.is_active, 1), + canEdit: 0, + isExist: true, + }, + }); + } + } + await tx.tenant.update({ + where: { id: tenantId }, + data: { appVersion: this.bumpAppVersion(tenant.appVersion) }, + }); }); await this.audit.log({ @@ -130,26 +166,73 @@ export class ProgramSettingsService { tenantId, userId, after: { - importedAt: payload.importedAt, count: incoming.length, keys: incoming.map((row) => row.key), } as Prisma.InputJsonValue, }); + const items = await this.listTenantRows(tenantId); return { tenant: this.tenantMeta(tenant), - importedAt: payload.importedAt, - source: payload.source, - apiBase: payload.apiBase, - items: this.omitRuntime(items), - count: this.omitRuntime(items).length, + items, + count: items.length, }; } - async importFromKhanegi( + /** پیش‌نمایش ایمپورت — برای هر کلید تصمیم‌گیری لازم است */ + async previewImport(tenantId: string, mobile: string, password: string) { + const tenant = await this.requireKhanegiTenant(tenantId); + const mobileNorm = this.normalizeAdminMobile(mobile); + if (!/^\d{11}$/.test(mobileNorm)) { + throw new BadRequestException('موبایل ادمین خانگی باید ۱۱ رقم باشد (مثلاً 0912…)'); + } + const { apiBase, token } = await this.loginKhanegiAdmin(tenant, mobileNorm, password); + const remote = await this.fetchSystemSettings(apiBase, token); + const local = await this.listTenantRows(tenantId); + const localByKey = new Map(local.map((row) => [String(row.key), row])); + const remoteByKey = new Map( + remote + .map((row) => this.normalizeIncoming(row)) + .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)) + .map((row) => [row.key, row]), + ); + + const decisionsNeeded: Array<{ + key: string; + title_fa: string; + khanegi_value: string; + superadmin_value: string | null; + status: 'new_in_khanegi' | 'both'; + }> = []; + + for (const [key, row] of remoteByKey) { + const localRow = localByKey.get(key); + decisionsNeeded.push({ + key, + title_fa: String(row.title_fa || localRow?.title_fa || key), + khanegi_value: String(row.value ?? ''), + superadmin_value: localRow ? String(localRow.value ?? '') : null, + status: localRow ? 'both' : 'new_in_khanegi', + }); + } + + const onlySuperAdmin = [...localByKey.keys()].filter((key) => !remoteByKey.has(key)); + + return { + tenant: this.tenantMeta(tenant), + apiBase, + decisionsNeeded, + onlySuperAdminKeys: onlySuperAdmin, + note: + 'برای هر کلید یکی از حالت‌ها را بفرستید: none | this | all. کلیدهایی که فقط در سوپرادمین‌اند با مقدار پیش‌فرض قالب روی این پروژه تکمیل می‌شوند.', + }; + } + + async applyImport( tenantId: string, mobile: string, password: string, + decisions: ImportDecision[], userId?: string, ) { const tenant = await this.requireKhanegiTenant(tenantId); @@ -157,25 +240,63 @@ export class ProgramSettingsService { if (!/^\d{11}$/.test(mobileNorm)) { throw new BadRequestException('موبایل ادمین خانگی باید ۱۱ رقم باشد (مثلاً 0912…)'); } - const { apiBase, token } = await this.loginKhanegiAdmin( - tenant, - mobileNorm, - password, + const { apiBase, token } = await this.loginKhanegiAdmin(tenant, mobileNorm, password); + const remote = await this.fetchSystemSettings(apiBase, token); + const remoteByKey = new Map( + remote + .map((row) => this.normalizeIncoming(row)) + .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)) + .map((row) => [row.key, row]), ); - const items = await this.fetchSystemSettings(apiBase, token); - const payload: ProgramSettingsPayload = { - importedAt: new Date().toISOString(), - source: 'khanegi_admin', - apiBase, - items, - }; - await this.prisma.tenant.update({ - where: { id: tenantId }, - data: { - setting: payload as unknown as Prisma.InputJsonValue, - appVersion: this.bumpAppVersion(tenant.appVersion), - }, + const decisionMap = new Map(); + for (const d of decisions || []) { + const key = String(d.key || '').trim(); + const scope = d.scope; + if (!key) continue; + if (scope !== 'none' && scope !== 'this' && scope !== 'all') { + throw new BadRequestException(`scope نامعتبر برای ${key}`); + } + decisionMap.set(key, scope); + } + + const khanegiTenantIds = ( + await this.prisma.tenant.findMany({ + where: { product: ProductType.khanegi }, + select: { id: true }, + }) + ).map((t: { id: string }) => t.id); + + await this.prisma.$transaction(async (tx: any) => { + // کلیدهایی که فقط در سوپرادمین‌اند → پیش‌فرض قالب برای این پروژه + await this.ensureTemplateDefaultsForTenant(tx, tenantId); + + for (const [key, row] of remoteByKey) { + const scope = decisionMap.get(key) ?? 'none'; + if (scope === 'none') continue; + + const targetIds = scope === 'all' ? khanegiTenantIds : [tenantId]; + for (const tid of targetIds) { + await this.upsertTenantSetting(tx, tid, { + ...row, + key, + can_edit: 0, + }); + } + + // کلید جدید از خانگی → وارد قالب هم بشود اگر نبود + const tpl = await tx.settingTemplate.findUnique({ where: { key } }); + if (!tpl) { + await tx.settingTemplate.create({ + data: this.templateDataFromRow(row), + }); + } + } + + await tx.tenant.update({ + where: { id: tenantId }, + data: { appVersion: this.bumpAppVersion(tenant.appVersion) }, + }); }); await this.audit.log({ @@ -185,30 +306,328 @@ export class ProgramSettingsService { tenantId, userId, after: { - importedAt: payload.importedAt, - count: items.length, - keys: items.map((row) => row.key).filter(Boolean), + decisions: [...decisionMap.entries()].map(([key, scope]) => ({ key, scope })), + apiBase, } as Prisma.InputJsonValue, - meta: { apiBase } as Prisma.InputJsonValue, }); + const items = await this.listTenantRows(tenantId); return { tenant: this.tenantMeta(tenant), - importedAt: payload.importedAt, - source: payload.source, apiBase, items, count: items.length, }; } + async listTemplates() { + await this.ensureTemplatesSeeded(); + const rows = await this.prisma.settingTemplate.findMany({ + where: { deletedAt: null }, + orderBy: { key: 'asc' }, + }); + return { + items: rows.map((row: SettingTemplate) => this.toApiRow(row)), + count: rows.length, + }; + } + + /** افزودن کلید(ها) به قالب + همهٔ نسخه‌های خانگی */ + async addKeys(itemsRaw: Array>, userId?: string) { + const incoming = this.asSettingsArray(itemsRaw) + .map((row) => this.normalizeIncoming(row)) + .filter((row) => row.key && !isRuntimeProgramSettingKey(row.key)); + if (!incoming.length) { + throw new BadRequestException('هیچ کلید معتبری ارسال نشده'); + } + + await this.ensureTemplatesSeeded(); + const tenants = await this.prisma.tenant.findMany({ + where: { product: ProductType.khanegi }, + select: { id: true }, + }); + + await this.prisma.$transaction(async (tx: any) => { + for (const row of incoming) { + await tx.settingTemplate.upsert({ + where: { key: row.key }, + create: this.templateDataFromRow(row), + update: { + titleFa: row.title_fa || row.key, + value: row.value, + description: row.description || '', + type: row.type || '', + typeData: row.type_data || null, + canEdit: this.toSmallInt(row.can_edit, 0), + isActive: this.toSmallInt(row.is_active, 1), + isExist: true, + deletedAt: null, + }, + }); + for (const t of tenants) { + const existing = await tx.setting.findUnique({ + where: { tenantId_key: { tenantId: t.id, key: row.key } }, + }); + if (existing) continue; + await this.upsertTenantSetting(tx, t.id, { ...row, can_edit: 0 }); + } + } + }); + + await this.audit.log({ + action: 'setting_templates.add_keys', + entityType: 'setting_template', + entityId: 'bulk', + userId, + after: { keys: incoming.map((r) => r.key), tenants: tenants.length } as Prisma.InputJsonValue, + }); + + return { + keys: incoming.map((r) => r.key), + tenantsUpdated: tenants.length, + count: incoming.length, + }; + } + + /** حذف کلید با اسم از قالب و همهٔ پروژه‌ها */ + async deleteKey(keyRaw: string, userId?: string) { + const key = String(keyRaw || '').trim(); + if (!key) throw new BadRequestException('key لازم است'); + + const result = await this.prisma.$transaction(async (tx: any) => { + const tpl = await tx.settingTemplate.deleteMany({ where: { key } }); + const settings = await tx.setting.deleteMany({ where: { key } }); + return { templates: tpl.count, settings: settings.count }; + }); + + await this.audit.log({ + action: 'setting_templates.delete_key', + entityType: 'setting_template', + entityId: key, + userId, + after: result as Prisma.InputJsonValue, + }); + + return { key, ...result }; + } + + /** کپی قالب روی نسخهٔ جدید خانگی */ + async seedTenantFromTemplates(tenantId: string) { + await this.ensureTemplatesSeeded(); + const templates = await this.prisma.settingTemplate.findMany({ + where: { deletedAt: null }, + }); + if (!templates.length) return { count: 0 }; + + await this.prisma.$transaction(async (tx: any) => { + for (const tpl of templates) { + await tx.setting.upsert({ + where: { tenantId_key: { tenantId, key: tpl.key } }, + create: { + tenantId, + key: tpl.key, + titleFa: tpl.titleFa, + value: tpl.value, + description: tpl.description, + type: tpl.type, + typeData: tpl.typeData, + canEdit: tpl.canEdit ?? 0, + isActive: tpl.isActive ?? 1, + isExist: tpl.isExist, + }, + update: {}, + }); + } + }); + return { count: templates.length }; + } + + async ensureTemplatesSeeded() { + const count = await this.prisma.settingTemplate.count(); + if (count > 0) return { seeded: false, count }; + + const seedPath = this.resolveSeedPath(); + if (!seedPath || !existsSync(seedPath)) { + return { seeded: false, count: 0, missingSeed: true }; + } + const raw = JSON.parse(readFileSync(seedPath, 'utf8')) as TemplateSeedRow[]; + if (!Array.isArray(raw) || !raw.length) { + return { seeded: false, count: 0 }; + } + + await this.prisma.$transaction(async (tx: any) => { + for (const row of raw) { + const key = String(row.key || '').trim(); + if (!key || isRuntimeProgramSettingKey(key)) continue; + await tx.settingTemplate.upsert({ + where: { key }, + create: { + key, + titleFa: row.title_fa || key, + value: row.value == null ? '' : String(row.value), + description: row.description == null ? '' : String(row.description), + type: row.type || '', + typeData: row.type_data || null, + canEdit: this.toSmallInt(row.can_edit, 0), + isActive: this.toSmallInt(row.is_active, 1), + isExist: row.is_exist === 0 || row.is_exist === false ? false : true, + }, + update: {}, + }); + } + }); + + const after = await this.prisma.settingTemplate.count(); + return { seeded: true, count: after }; + } + + private async seedEmptyKhanegiTenants() { + const tenants = await this.prisma.tenant.findMany({ + where: { product: ProductType.khanegi }, + select: { id: true, _count: { select: { settings: true } } }, + }); + for (const t of tenants as Array<{ id: string; _count: { settings: number } }>) { + if ((t._count?.settings ?? 0) > 0) continue; + await this.seedTenantFromTemplates(t.id); + } + } + + private resolveSeedPath() { + const candidates = [ + join(process.cwd(), 'prisma/setting-templates-from-deploy.json'), + join(__dirname, '../../prisma/setting-templates-from-deploy.json'), + join(process.cwd(), 'prisma/data/setting-templates-from-deploy.json'), + ]; + return candidates.find((p) => existsSync(p)) || null; + } + + private async listTenantRows(tenantId: string): Promise { + const rows = await this.prisma.setting.findMany({ + where: { tenantId, deletedAt: null }, + orderBy: { key: 'asc' }, + }); + return rows + .filter((row: Setting) => !isRuntimeProgramSettingKey(row.key)) + .map((row: Setting) => this.toApiRow(row)); + } + + private toApiRow(row: Setting | SettingTemplate): ProgramSettingRow { + return { + id: row.id, + key: row.key, + value: row.value ?? '', + title_fa: row.titleFa ?? '', + description: row.description ?? '', + type: row.type ?? '', + type_data: row.typeData ?? '', + is_active: row.isActive ?? 1, + can_edit: row.canEdit ?? 0, + is_exist: row.isExist ? 1 : 0, + }; + } + + private normalizeIncoming(row: ProgramSettingRow | Record) { + const r = row as Record; + return { + key: String(r.key || '').trim(), + value: r.value == null ? '' : String(r.value), + title_fa: r.title_fa == null ? '' : String(r.title_fa), + description: r.description == null ? '' : String(r.description), + type: r.type == null ? '' : String(r.type), + type_data: r.type_data == null ? null : String(r.type_data), + is_active: r.is_active, + can_edit: r.can_edit, + is_exist: r.is_exist, + }; + } + + private templateDataFromRow(row: ReturnType) { + return { + key: row.key, + titleFa: row.title_fa || row.key, + value: row.value, + description: row.description || '', + type: row.type || '', + typeData: row.type_data || null, + canEdit: this.toSmallInt(row.can_edit, 0), + isActive: this.toSmallInt(row.is_active, 1), + isExist: true, + deletedAt: null as Date | null, + }; + } + + private async upsertTenantSetting( + tx: any, + tenantId: string, + row: ReturnType, + ) { + await tx.setting.upsert({ + where: { tenantId_key: { tenantId, key: row.key } }, + create: { + tenantId, + key: row.key, + titleFa: row.title_fa || row.key, + value: row.value, + description: row.description || '', + type: row.type || '', + typeData: row.type_data || null, + canEdit: this.toSmallInt(row.can_edit, 0), + isActive: this.toSmallInt(row.is_active, 1), + isExist: true, + }, + update: { + value: row.value, + titleFa: row.title_fa || row.key, + description: row.description || '', + type: row.type || '', + typeData: row.type_data || null, + canEdit: this.toSmallInt(row.can_edit, 0), + isActive: this.toSmallInt(row.is_active, 1), + isExist: true, + deletedAt: null, + }, + }); + } + + private async ensureTemplateDefaultsForTenant( + tx: any, + tenantId: string, + ) { + const templates = await tx.settingTemplate.findMany({ where: { deletedAt: null } }); + for (const tpl of templates) { + const existing = await tx.setting.findUnique({ + where: { tenantId_key: { tenantId, key: tpl.key } }, + }); + if (existing) continue; + await tx.setting.create({ + data: { + tenantId, + key: tpl.key, + titleFa: tpl.titleFa, + value: tpl.value, + description: tpl.description, + type: tpl.type, + typeData: tpl.typeData, + canEdit: tpl.canEdit ?? 0, + isActive: tpl.isActive ?? 1, + isExist: tpl.isExist, + }, + }); + } + } + + private toSmallInt(value: unknown, fallback: number) { + if (value === 0 || value === false || value === '0') return 0; + if (value === 1 || value === true || value === '1') return 1; + if (typeof value === 'number' && Number.isFinite(value)) return value; + return fallback; + } + private async requireKhanegiTenant(tenantId: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); - if (!tenant) { - throw new NotFoundException('نسخه یافت نشد'); - } + if (!tenant) throw new NotFoundException('نسخه یافت نشد'); if (tenant.product !== ProductType.khanegi) { - throw new ForbiddenException('تنظیمات برنامه فعلاً فقط برای خانگی فعال است'); + throw new ForbiddenException('تنظیمات برنامه فقط برای خانگی است'); } return tenant; } @@ -237,48 +656,22 @@ export class ProgramSettingsService { }; } - private readStored(raw: Prisma.JsonValue | null): ProgramSettingsPayload | null { - if (!raw || typeof raw !== 'object' || Array.isArray(raw)) { - if (Array.isArray(raw)) { - return { - importedAt: '', - source: 'khanegi_admin', - apiBase: '', - items: this.asSettingsArray(raw).filter((row) => - this.isSystemSetting(row), - ), - }; - } - return null; - } - const obj = raw as Record; - const items = this.asSettingsArray(obj.items ?? obj); - return { - importedAt: String(obj.importedAt || ''), - source: obj.source === 'superadmin' ? 'superadmin' : 'khanegi_admin', - apiBase: String(obj.apiBase || ''), - items, - }; - } - private async findKhanegiTenantByHost(hostRaw: string) { const host = this.normalizeHost(hostRaw); - if (!host) { - throw new NotFoundException('domain required'); - } + if (!host) throw new NotFoundException('domain required'); const tenants = await this.prisma.tenant.findMany({ where: { isActive: true, product: ProductType.khanegi }, }); const hostNoWww = host.replace(/^www\./, ''); - const primary = tenants.find((item) => { + const primary = tenants.find((item: { domain: string }) => { const d = this.normalizeHost(item.domain); return d === host || d.replace(/^www\./, '') === hostNoWww; }); const tenant = primary || - tenants.find((item) => { + tenants.find((item: { domain: string; domains: unknown }) => { for (const value of this.readDomains(item.domains)) { const normalized = this.normalizeHost(value); if (!normalized) continue; @@ -289,9 +682,7 @@ export class ProgramSettingsService { return false; }); - if (!tenant) { - throw new NotFoundException('tenant not found for domain'); - } + if (!tenant) throw new NotFoundException('tenant not found for domain'); return tenant; } @@ -311,104 +702,53 @@ export class ProgramSettingsService { private bumpAppVersion(current?: string | null): string { const v = String(current || '1').trim() || '1'; - if (/^\d+$/.test(v)) { - return String(Number(v) + 1); - } + if (/^\d+$/.test(v)) return String(Number(v) + 1); const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/); - if (semver) { - return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`; - } + if (semver) return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`; return `${Date.now()}`; } private async loginKhanegiAdmin( - tenant: { - nodeServer: string; - domain: string; - }, + tenant: { nodeServer: string; domain: string }, mobile: string, password: string, - ): Promise<{ apiBase: string; token: string }> { - const candidates = this.apiBaseCandidates(tenant); - if (!candidates.length) { - throw new BadRequestException( - 'آدرس API نسخه (node_server) تنظیم نشده؛ اول در مدیریت نسخه ذخیره کنید.', + ) { + const apiBase = this.normalizeApiBase(tenant.nodeServer); + if (!apiBase) { + throw new BadRequestException('آدرس API نسخه (node_server) تنظیم نشده'); + } + const url = this.joinUrl(apiBase, '/admin/login'); + try { + const { data } = await axios.post( + url, + { mobile, password }, + { timeout: 25000, validateStatus: () => true }, + ); + if (data?.status === false || data?.success === false) { + throw new BadRequestException( + this.laravelMessage(data) || 'ورود ادمین خانگی ناموفق بود', + ); + } + const token = this.extractAccessToken(data); + if (!token) { + throw new BadRequestException('توکن ادمین خانگی دریافت نشد'); + } + return { apiBase, token }; + } catch (error) { + if (error instanceof HttpException) throw error; + throw new BadGatewayException( + this.axiosMessage(error, 'اتصال به لاگین ادمین خانگی ناموفق بود'), ); } - - let lastError: unknown; - for (const apiBase of candidates) { - try { - const res = await axios.post( - this.joinUrl(apiBase, '/auth/login'), - { mobile: mobile.trim(), password, provider: 'user' }, - { - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json', - }, - timeout: 20000, - validateStatus: () => true, - }, - ); - - if (res.status === 404 || res.status === 405) { - lastError = new BadGatewayException(`ورود روی ${apiBase} یافت نشد`); - continue; - } - if (res.status >= 500) { - lastError = new BadGatewayException( - this.laravelMessage(res.data) || `سرور خانگی ${apiBase} خطا داد`, - ); - continue; - } - if (res.status === 401 || res.status === 422 || res.status === 400) { - throw new BadGatewayException( - this.laravelMessage(res.data) || - 'نام کاربری یا رمز عبور ادمین خانگی اشتباه است', - ); - } - - const token = this.extractAccessToken(res.data); - if (token) { - return { apiBase, token }; - } - - lastError = new BadGatewayException( - this.laravelMessage(res.data) || - `ورود به ادمین خانگی از ${apiBase} توکن برنگرداند`, - ); - } catch (error) { - if (error instanceof HttpException) throw error; - lastError = error; - const status = axios.isAxiosError(error) ? error.response?.status : undefined; - if (status === 401 || status === 422 || status === 400) { - throw new BadGatewayException( - this.axiosMessage(error, 'نام کاربری یا رمز عبور ادمین خانگی اشتباه است'), - ); - } - } - } - - throw new BadGatewayException( - this.axiosMessage( - lastError, - 'ورود به API ادمین خانگی ناموفق بود — node_server و دامنه نسخه را چک کنید', - ), - ); } private async fetchSystemSettings(apiBase: string, token: string) { + const headers = { Authorization: `Bearer ${token}` }; const url = this.joinUrl(apiBase, '/admin/get_settings'); - const headers = { - Authorization: `Bearer ${token}`, - Accept: 'application/json', - }; - try { const { data } = await axios.get(url, { - params: { system_only: 1 }, headers, + params: { system_only: 1 }, timeout: 25000, }); const items = this.asSettingsArray(data) @@ -418,7 +758,7 @@ export class ProgramSettingsService { } catch (error) { const status = axios.isAxiosError(error) ? error.response?.status : undefined; if (status === 401 || status === 403) { - throw new BadGatewayException( + throw new BadRequestException( this.axiosMessage(error, 'توکن ادمین خانگی برای get_settings رد شد'), ); } @@ -436,74 +776,17 @@ export class ProgramSettingsService { } } - private normalizeAdminMobile(mobile: string): string { - let digits = String(mobile || '').replace(/\D+/g, ''); - if (digits.startsWith('98') && digits.length >= 12) { - digits = digits.slice(2); - } - if (digits.length === 10 && digits.startsWith('9')) { - digits = `0${digits}`; - } - return digits; + private normalizeAdminMobile(mobile: string) { + let m = String(mobile || '').replace(/\D/g, ''); + if (m.startsWith('98') && m.length === 12) m = `0${m.slice(2)}`; + return m; } - private apiBaseCandidates(tenant: { nodeServer: string; domain: string }): string[] { - const out: string[] = []; - const push = (value?: string | null) => { - const trimmed = String(value || '') - .trim() - .replace(/\/$/, ''); - if (trimmed && !out.includes(trimmed)) out.push(trimmed); - }; - - push(tenant.nodeServer); - const host = this.normalizeHost(tenant.domain); - if (host) { - push(`https://${host}/server`); - push(`https://${host}/api`); - push(`https://${host}`); - } - return out; - } - - private extractAccessToken(data: unknown): string { - if (!data || typeof data !== 'object') return ''; - const obj = data as Record; - const nested = - obj.data && typeof obj.data === 'object' - ? (obj.data as Record) - : null; - const raw = obj.access_token ?? nested?.access_token ?? obj.token ?? nested?.token; - return String(raw || '').trim(); - } - - private omitRuntime(items: ProgramSettingRow[]): ProgramSettingRow[] { - return items.filter((row) => !isRuntimeProgramSettingKey(String(row.key || ''))); - } - - private asSettingsArray(data: unknown): ProgramSettingRow[] { - if (Array.isArray(data)) return data as ProgramSettingRow[]; - if (data && typeof data === 'object') { - const obj = data as Record; - if (Array.isArray(obj.data)) return obj.data as ProgramSettingRow[]; - if (Array.isArray(obj.settings)) return obj.settings as ProgramSettingRow[]; - if (Array.isArray(obj.items)) return obj.items as ProgramSettingRow[]; - } - return []; - } - - private isSystemSetting(row: ProgramSettingRow): boolean { - if (!this.isLocked(row?.can_edit)) return false; - return this.isActive(row?.is_active); - } - - private isLocked(canEdit: ProgramSettingRow['can_edit']): boolean { - return canEdit === 0 || canEdit === false || canEdit === '0'; - } - - private isActive(isActive: ProgramSettingRow['is_active']): boolean { - if (isActive === 0 || isActive === false || isActive === '0') return false; - return isActive !== null && isActive !== undefined; + private normalizeApiBase(raw: string) { + const s = String(raw || '').trim().replace(/\/$/, ''); + if (!s) return ''; + if (/^https?:\/\//i.test(s)) return s; + return `https://${s}`; } private normalizeHost(value?: string | null) { @@ -520,6 +803,37 @@ export class ProgramSettingsService { return `${base.replace(/\/$/, '')}${path}`; } + private extractAccessToken(data: unknown): string { + if (!data || typeof data !== 'object') return ''; + const obj = data as Record; + const nested = + obj.data && typeof obj.data === 'object' + ? (obj.data as Record) + : null; + const raw = obj.access_token ?? nested?.access_token ?? obj.token ?? nested?.token; + return String(raw || '').trim(); + } + + private asSettingsArray(data: unknown): ProgramSettingRow[] { + if (Array.isArray(data)) return data as ProgramSettingRow[]; + if (data && typeof data === 'object') { + const obj = data as Record; + if (Array.isArray(obj.data)) return obj.data as ProgramSettingRow[]; + if (Array.isArray(obj.settings)) return obj.settings as ProgramSettingRow[]; + if (Array.isArray(obj.items)) return obj.items as ProgramSettingRow[]; + } + return []; + } + + private isSystemSetting(row: ProgramSettingRow): boolean { + const canEdit = row?.can_edit; + const locked = canEdit === 0 || canEdit === false || canEdit === '0'; + if (!locked) return false; + const isActive = row?.is_active; + if (isActive === 0 || isActive === false || isActive === '0') return false; + return isActive !== null && isActive !== undefined; + } + private laravelMessage(data: unknown): string { if (!data || typeof data !== 'object') { return typeof data === 'string' ? data : ''; @@ -547,9 +861,7 @@ export class ProgramSettingsService { } return `${fallback}${status ? ` (HTTP ${status})` : ''}`; } - if (error instanceof Error && error.message) { - return `${fallback}: ${error.message}`; - } + if (error instanceof Error && error.message) return `${fallback}: ${error.message}`; return fallback; } } diff --git a/src/program-settings/setting-templates.controller.ts b/src/program-settings/setting-templates.controller.ts new file mode 100644 index 0000000..ced5fd0 --- /dev/null +++ b/src/program-settings/setting-templates.controller.ts @@ -0,0 +1,42 @@ +import { Body, Controller, Delete, Get, Post, Query, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { PermissionsGuard } from '../auth/permissions.guard'; +import { RequirePermissions } from '../auth/permissions.decorator'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { AddSettingKeysDto, DeleteSettingKeyDto } from './dto/setting-keys.dto'; +import { ProgramSettingsService } from './program-settings.service'; + +@Controller('setting-templates') +@UseGuards(JwtAuthGuard, PermissionsGuard) +export class SettingTemplatesController { + constructor(private readonly programSettings: ProgramSettingsService) {} + + @Get() + @RequirePermissions('khanegi:read') + list() { + return this.programSettings.listTemplates(); + } + + @Post('keys') + @RequirePermissions('khanegi:write') + addKeys(@Body() body: AddSettingKeysDto, @CurrentUser() user: { id: string }) { + return this.programSettings.addKeys(body.items || [], user.id); + } + + @Delete('keys') + @RequirePermissions('khanegi:write') + removeKey( + @Query('key') keyQuery: string | undefined, + @Body() body: DeleteSettingKeyDto | undefined, + @CurrentUser() user: { id: string }, + ) { + const key = body?.key || keyQuery || ''; + return this.programSettings.deleteKey(key, user.id); + } + + @Post('seed') + @RequirePermissions('khanegi:write') + seed() { + return this.programSettings.ensureTemplatesSeeded(); + } +} diff --git a/src/tenants/dto/create-tenant.dto.ts b/src/tenants/dto/create-tenant.dto.ts index 6e06e3e..e948a41 100644 --- a/src/tenants/dto/create-tenant.dto.ts +++ b/src/tenants/dto/create-tenant.dto.ts @@ -1,4 +1,5 @@ import { + IsArray, IsBoolean, IsEnum, IsObject, @@ -37,6 +38,13 @@ export class CreateTenantDto { @MaxLength(255) domain!: string; + /** Extra hosts / IPs for /get_user (aliases). Unique per product, not across hamkar/khanegi. */ + @IsOptional() + @IsArray() + @IsString({ each: true }) + @MaxLength(255, { each: true }) + domains?: string[]; + @IsOptional() @IsString() avatarUrl?: string; diff --git a/src/tenants/tenants.module.ts b/src/tenants/tenants.module.ts index dfde743..e081772 100644 --- a/src/tenants/tenants.module.ts +++ b/src/tenants/tenants.module.ts @@ -1,11 +1,12 @@ -import { Module } from '@nestjs/common'; +import { Module, forwardRef } from '@nestjs/common'; import { AuthModule } from '../auth/auth.module'; import { NotificationsModule } from '../notifications/notifications.module'; +import { ProgramSettingsModule } from '../program-settings/program-settings.module'; import { TenantsController } from './tenants.controller'; import { TenantsService } from './tenants.service'; @Module({ - imports: [NotificationsModule, AuthModule], + imports: [NotificationsModule, AuthModule, forwardRef(() => ProgramSettingsModule)], controllers: [TenantsController], providers: [TenantsService], exports: [TenantsService], diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 8c1c934..db2a608 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -7,6 +7,7 @@ import { Prisma, ProductType, Tenant } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; import { AdminNotifyService } from '../notifications/admin-notify.service'; +import { ProgramSettingsService } from '../program-settings/program-settings.service'; import { CreateTenantDto } from './dto/create-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto'; @@ -16,6 +17,7 @@ export class TenantsService { private readonly prisma: PrismaService, private readonly audit: AuditService, private readonly notify: AdminNotifyService, + private readonly programSettings: ProgramSettingsService, ) {} async findAll(product?: ProductType) { @@ -29,6 +31,7 @@ export class TenantsService { nameFa: true, nameEn: true, domain: true, + domains: true, avatarUrl: true, primaryColor: true, secondaryColor: true, @@ -38,20 +41,20 @@ export class TenantsService { appUrl: true, adminUrl: true, appUiConfig: true, - setting: true, uiVariant: true, appVersion: true, isActive: true, notes: true, createdAt: true, updatedAt: true, + _count: { select: { settings: true } }, }, }); - return rows.map(({ appUiConfig, setting, ...rest }) => ({ + return rows.map(({ appUiConfig, _count, ...rest }: any) => ({ ...rest, hasAppUiConfig: Boolean(appUiConfig), - hasProgramSettings: Boolean(setting), + hasProgramSettings: (_count?.settings ?? 0) > 0, })); } @@ -64,36 +67,50 @@ export class TenantsService { } async create(dto: CreateTenantDto, userId?: string) { + const aliases = this.uniqueNormalizedHosts(dto.domains); try { - const tenant = await this.prisma.tenant.create({ - data: { - product: dto.product, - slug: dto.slug.toLowerCase(), - nameFa: dto.nameFa, - nameEn: dto.nameEn, - domain: this.normalizeDomain(dto.domain), - avatarUrl: dto.avatarUrl, - primaryColor: dto.primaryColor, - secondaryColor: dto.secondaryColor, - backgroundColor: dto.backgroundColor, - nodeServer: dto.nodeServer, - nodeWs: dto.nodeWs, - appUrl: dto.appUrl, - adminUrl: dto.adminUrl, - remoteApiToken: dto.remoteApiToken, - appUiConfig: dto.appUiConfig - ? (dto.appUiConfig as Prisma.InputJsonValue) - : undefined, - uiVariant: - dto.product === ProductType.khanegi - ? dto.uiVariant ?? 'modular' - : 'modular', - appVersion: dto.appVersion?.trim() || '1', - isActive: dto.isActive ?? true, - notes: dto.notes, - }, + const tenant = await this.prisma.$transaction(async (tx: any) => { + const created = await tx.tenant.create({ + data: { + product: dto.product, + slug: dto.slug.toLowerCase(), + nameFa: dto.nameFa, + nameEn: dto.nameEn, + domain: this.normalizeDomain(dto.domain), + domains: aliases as unknown as Prisma.InputJsonValue, + avatarUrl: dto.avatarUrl, + primaryColor: dto.primaryColor, + secondaryColor: dto.secondaryColor, + backgroundColor: dto.backgroundColor, + nodeServer: dto.nodeServer, + nodeWs: dto.nodeWs, + appUrl: dto.appUrl, + adminUrl: dto.adminUrl, + remoteApiToken: dto.remoteApiToken, + appUiConfig: dto.appUiConfig + ? (dto.appUiConfig as Prisma.InputJsonValue) + : undefined, + uiVariant: + dto.product === ProductType.khanegi + ? dto.uiVariant ?? 'modular' + : 'modular', + appVersion: dto.appVersion?.trim() || '1', + isActive: dto.isActive ?? true, + notes: dto.notes, + }, + }); + await this.reclaimAliasesFromSameProduct(tx, { + tenantId: created.id, + product: created.product, + aliases, + }); + return created; }); + if (tenant.product === ProductType.khanegi) { + await this.programSettings.seedTenantFromTemplates(tenant.id); + } + await this.audit.log({ action: 'tenant.create', entityType: 'tenant', @@ -120,6 +137,13 @@ export class TenantsService { throw new NotFoundException('نسخه یافت نشد'); } + const nextAliases = + dto.domains !== undefined ? this.uniqueNormalizedHosts(dto.domains) : undefined; + const aliasesChanged = + nextAliases !== undefined && + JSON.stringify(this.uniqueNormalizedHosts(this.readDomains(before.domains))) !== + JSON.stringify(nextAliases); + const brandingChanged = (dto.avatarUrl !== undefined && dto.avatarUrl !== before.avatarUrl) || (dto.primaryColor !== undefined && dto.primaryColor !== before.primaryColor) || @@ -130,7 +154,8 @@ export class TenantsService { dto.appUiConfig !== undefined || (dto.uiVariant !== undefined && dto.uiVariant !== before.uiVariant) || (dto.nameFa !== undefined && dto.nameFa !== before.nameFa) || - (dto.nameEn !== undefined && dto.nameEn !== before.nameEn); + (dto.nameEn !== undefined && dto.nameEn !== before.nameEn) || + aliasesChanged; const explicitVersion = dto.appVersion !== undefined ? dto.appVersion.trim() || '1' : undefined; @@ -139,43 +164,56 @@ export class TenantsService { (brandingChanged ? this.bumpAppVersion(before.appVersion) : undefined); try { - const tenant = await this.prisma.tenant.update({ - where: { id }, - data: { - ...(dto.product !== undefined && { product: dto.product }), - ...(dto.slug !== undefined && { slug: dto.slug.toLowerCase() }), - ...(dto.nameFa !== undefined && { nameFa: dto.nameFa }), - ...(dto.nameEn !== undefined && { nameEn: dto.nameEn }), - ...(dto.domain !== undefined && { - domain: this.normalizeDomain(dto.domain), - }), - ...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }), - ...(dto.primaryColor !== undefined && { - primaryColor: dto.primaryColor, - }), - ...(dto.secondaryColor !== undefined && { - secondaryColor: dto.secondaryColor, - }), - ...(dto.backgroundColor !== undefined && { - backgroundColor: dto.backgroundColor, - }), - ...(dto.nodeServer !== undefined && { nodeServer: dto.nodeServer }), - ...(dto.nodeWs !== undefined && { nodeWs: dto.nodeWs }), - ...(dto.appUrl !== undefined && { appUrl: dto.appUrl }), - ...(dto.adminUrl !== undefined && { adminUrl: dto.adminUrl }), - ...(dto.remoteApiToken !== undefined && { - remoteApiToken: dto.remoteApiToken, - }), - ...(dto.appUiConfig !== undefined && { - appUiConfig: dto.appUiConfig as Prisma.InputJsonValue, - }), - ...(dto.uiVariant !== undefined && { uiVariant: dto.uiVariant }), - ...(nextAppVersion !== undefined && { - appVersion: nextAppVersion, - }), - ...(dto.isActive !== undefined && { isActive: dto.isActive }), - ...(dto.notes !== undefined && { notes: dto.notes }), - }, + const tenant = await this.prisma.$transaction(async (tx: any) => { + if (nextAliases !== undefined) { + await this.reclaimAliasesFromSameProduct(tx, { + tenantId: id, + product: dto.product ?? before.product, + aliases: nextAliases, + }); + } + + return tx.tenant.update({ + where: { id }, + data: { + ...(dto.product !== undefined && { product: dto.product }), + ...(dto.slug !== undefined && { slug: dto.slug.toLowerCase() }), + ...(dto.nameFa !== undefined && { nameFa: dto.nameFa }), + ...(dto.nameEn !== undefined && { nameEn: dto.nameEn }), + ...(dto.domain !== undefined && { + domain: this.normalizeDomain(dto.domain), + }), + ...(nextAliases !== undefined && { + domains: nextAliases as unknown as Prisma.InputJsonValue, + }), + ...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }), + ...(dto.primaryColor !== undefined && { + primaryColor: dto.primaryColor, + }), + ...(dto.secondaryColor !== undefined && { + secondaryColor: dto.secondaryColor, + }), + ...(dto.backgroundColor !== undefined && { + backgroundColor: dto.backgroundColor, + }), + ...(dto.nodeServer !== undefined && { nodeServer: dto.nodeServer }), + ...(dto.nodeWs !== undefined && { nodeWs: dto.nodeWs }), + ...(dto.appUrl !== undefined && { appUrl: dto.appUrl }), + ...(dto.adminUrl !== undefined && { adminUrl: dto.adminUrl }), + ...(dto.remoteApiToken !== undefined && { + remoteApiToken: dto.remoteApiToken, + }), + ...(dto.appUiConfig !== undefined && { + appUiConfig: dto.appUiConfig as Prisma.InputJsonValue, + }), + ...(dto.uiVariant !== undefined && { uiVariant: dto.uiVariant }), + ...(nextAppVersion !== undefined && { + appVersion: nextAppVersion, + }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + ...(dto.notes !== undefined && { notes: dto.notes }), + }, + }); }); await this.audit.log({ @@ -198,6 +236,9 @@ export class TenantsService { if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) { bits.push('هویت/دامنه تغییر کرد'); } + if (aliasesChanged) { + bits.push('دامنه‌های اضافی به‌روز شد'); + } this.notify.notifyAdmins( bits.length ? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}` @@ -236,20 +277,90 @@ export class TenantsService { } private sanitize(tenant: Tenant) { - const { remoteApiToken, setting, ...rest } = tenant; + const { remoteApiToken, ...rest } = tenant; return { ...rest, + domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)), hasRemoteApiToken: Boolean(remoteApiToken), - hasProgramSettings: Boolean(setting), }; } private normalizeDomain(domain: string) { - return domain + return this.normalizeHost(domain); + } + + /** هم‌تراز get_user: پروتکل، مسیر و پورت حذف می‌شود. */ + private normalizeHost(value: string) { + return String(value || '') .trim() .toLowerCase() .replace(/^https?:\/\//, '') - .replace(/\/$/, ''); + .split('/')[0] + .split(':')[0]; + } + + private hostKey(value: string) { + return this.normalizeHost(value).replace(/^www\./, ''); + } + + private readDomains(raw: unknown): string[] { + if (!raw) return []; + if (Array.isArray(raw)) return raw.map(String); + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed.map(String) : []; + } catch { + return []; + } + } + return []; + } + + private uniqueNormalizedHosts(list: unknown): string[] { + const seen = new Set(); + const out: string[] = []; + for (const item of Array.isArray(list) ? list : []) { + const host = this.normalizeHost(String(item || '')); + if (!host) continue; + const key = this.hostKey(host); + if (seen.has(key)) continue; + seen.add(key); + out.push(host); + } + return out; + } + + /** + * دامنه/IP در aliases این نسخه، از domains[] نسخه‌های دیگر همان محصول برداشته می‌شود. + * خانگی و همکار جدا هستند؛ دامنهٔ اصلی (ستون domain) جابه‌جا نمی‌شود. + */ + private async reclaimAliasesFromSameProduct( + tx: any, + opts: { tenantId: string; product: ProductType; aliases: string[] }, + ) { + const keys = new Set(opts.aliases.map((item) => this.hostKey(item)).filter(Boolean)); + if (!keys.size) return; + + const others = await tx.tenant.findMany({ + where: { product: opts.product, id: { not: opts.tenantId } }, + select: { id: true, nameFa: true, domain: true, domains: true }, + }); + + for (const other of others) { + if (keys.has(this.hostKey(other.domain))) { + throw new ConflictException( + `«${other.domain}» دامنهٔ اصلی نسخه «${other.nameFa}» است و نمی‌توان آن را به این پروژه منتقل کرد`, + ); + } + const current = this.readDomains(other.domains); + const next = current.filter((item) => !keys.has(this.hostKey(item))); + if (next.length === current.length) continue; + await tx.tenant.update({ + where: { id: other.id }, + data: { domains: next as unknown as Prisma.InputJsonValue }, + }); + } } /** با تغییر برندینگ، get_user کش فرانت با get_version باطل شود */