From 448dc444c94ac50283c7fe0a95cf3819fbe2782a Mon Sep 17 00:00:00 2001 From: Mahdi Date: Sat, 8 Aug 2026 15:30:43 +0330 Subject: [PATCH] Add Khanegi SMS sync inventory, batch import, and rollout helpers. Co-authored-by: Cursor --- .gitignore | 5 + deploy/SMS-MIGRATION-STEPS.md | 202 ++++++++++++++++++++++++ deploy/khanegi-sms-enable-remote.sh | 51 ++++++ deploy/khanegi-sms-import-one.sh | 49 ++++++ deploy/run-khanegi-sms-inventory.sh | 8 + package.json | 2 + prisma/import-sms-batch.ts | 138 ++++++++++++++++ prisma/import-sms-from-product.ts | 7 + prisma/inventory-khanegi-sms.ts | 200 +++++++++++++++++++++++ prisma/khanegi-sms-targets.example.json | 28 ++++ prisma/list-tenants.ts | 3 + 11 files changed, 693 insertions(+) create mode 100644 deploy/SMS-MIGRATION-STEPS.md create mode 100644 deploy/khanegi-sms-enable-remote.sh create mode 100644 deploy/khanegi-sms-import-one.sh create mode 100644 deploy/run-khanegi-sms-inventory.sh create mode 100644 prisma/import-sms-batch.ts create mode 100644 prisma/inventory-khanegi-sms.ts create mode 100644 prisma/khanegi-sms-targets.example.json diff --git a/.gitignore b/.gitignore index 9992ee6..6bbb333 100644 --- a/.gitignore +++ b/.gitignore @@ -10,6 +10,11 @@ coverage .env.local .env.*.local +# SMS import target files may contain live DB passwords +prisma/khanegi-sms-targets.json +prisma/*-sms-targets.json +!prisma/khanegi-sms-targets.example.json + # prisma / sqlite prisma/dev.db prisma/dev.db-journal diff --git a/deploy/SMS-MIGRATION-STEPS.md b/deploy/SMS-MIGRATION-STEPS.md new file mode 100644 index 0000000..ec39517 --- /dev/null +++ b/deploy/SMS-MIGRATION-STEPS.md @@ -0,0 +1,202 @@ +# مراحل ساده: مهاجرت SMS و کار موازی + +هدف: اول داده واقعی همکار/خانگی برود داخل سوپرادمین، بعد هر دو پنل با هم کار کنند تا خودت بگویی پنل محصولات حذف شود. + +--- + +## الف) یک نسخه پایلوت (مثلاً همکار) + +جای `DOMAIN` دامنه همان نسخه را بگذار (همان دامنه داخل سوپرادمین). + +### ۱) اگر قبلاً روی همکار `SUPERADMIN_SMS_*` گذاشتی، موقتاً خاموش کن + +روی سرور همکار در `.env` این خطوط را کامنت یا حذف کن: + +```env +# SUPERADMIN_SMS_BASE_URL=... +# SUPERADMIN_SMS_SHARED_SECRET=... +# SUPERADMIN_SMS_DOMAIN=... +``` + +بعد: + +```bash +php artisan config:clear +php artisan config:cache +``` + +علت: تا قبل از مهاجرت، پنل همکار باید همان داده واقعی محلی را نشان بدهد. + +### ۲) اعداد فعلی همکار را یادداشت کن + +از پنل پیامک ادمین همکار: +- موجودی +- قیمت هر پارت +- وضعیت بلاک + +### ۳) سوپرادمین را با کد جدید آپدیت کن + +روی سرور سوپرادمین (بعد از pull/build کد جدید): + +```bash +cd /var/www/superadmin/api +pm2 restart superadmin-api +``` + +### ۴) مهاجرت خشک (فقط نمایش، بدون نوشتن) + +روی سرور سوپرادمین، با دسترسی به MySQL همان نسخه همکار: + +```bash +cd /var/www/superadmin/api + +SMS_IMPORT_DRY_RUN=1 \ +SMS_IMPORT_DOMAIN=DOMAIN \ +SMS_IMPORT_PRODUCT=hamkar \ +HAMKAR_DB_HOST=IP_یا_127.0.0.1 \ +HAMKAR_DB_PORT=3306 \ +HAMKAR_DB_USER=USER \ +HAMKAR_DB_PASSWORD='PASS' \ +HAMKAR_DB_DATABASE=DBNAME \ +npm run prisma:import-sms-from-product +``` + +اگر گفت `SKIP: sms_panel=me` → این نسخه پنل اختصاصی دارد؛ مهاجرت لازم نیست. +اگر دامنه پیدا نشد → دامنه را با همان مقدار داخل سوپرادمین چک کن. + +### ۵) مهاجرت واقعی + +همان دستور بدون `SMS_IMPORT_DRY_RUN=1`: + +```bash +SMS_IMPORT_DOMAIN=DOMAIN \ +SMS_IMPORT_PRODUCT=hamkar \ +HAMKAR_DB_HOST=... \ +HAMKAR_DB_PORT=3306 \ +HAMKAR_DB_USER=... \ +HAMKAR_DB_PASSWORD='...' \ +HAMKAR_DB_DATABASE=... \ +npm run prisma:import-sms-from-product +``` + +### ۶) در سوپرادمین چک کن + +همان نسخه را باز کن؛ موجودی/تعرفه/بلاک باید با یادداشت مرحله ۲ یکی باشد. + +### ۷) کد همکار را آپدیت کن و remote را روشن کن + +بعد از دیپلوی کد جدید همکار، در `.env` همکار: + +```env +SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms +SUPERADMIN_SMS_SHARED_SECRET=همان_رمز_سوپرادمین +# فقط اگر APP_URL دامنه درست ندارد: +# SUPERADMIN_SMS_DOMAIN=DOMAIN +``` + +```bash +php artisan config:clear +php artisan config:cache +``` + +### ۸) تست موازی + +1. پنل همکار و سوپرادمین را باز کن → موجودی یکی باشد +2. در سوپرادمین شارژ کن → در همکار رفرش → باید یکی شود +3. در همکار تعرفه/بلاک عوض کن → در سوپرادمین همان را ببین +4. یک SMS تست بفرست → موجودی هر دو کم شود + +--- + +## ب) خانگی (چندنسخه‌ای روی یک سرور) + +قانون: هر نسخه = یک Tenant = یک MySQL = یک `.env` = یک `SUPERADMIN_SMS_DOMAIN`. +روی یک سرور فیزیکی ممکن است چند نصب باشد؛ هرگز یک دامنه/DB مشترک برای همه نگذار. + +### ب-۱) موجودی‌گیری از سوپرادمین + +```bash +cd /var/www/superadmin/api +git pull +npm run prisma:inventory-khanegi-sms +# یا: +SMS_INVENTORY_FORMAT=json SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json \ + npm run prisma:inventory-khanegi-sms +``` + +خروجی `syncStatus` / `nextAction` و لیست `multiVersionHosts` را نگه دار. + +### ب-۲) فایل اهداف ایمپورت + +از روی موجودی، فایل واقعی بساز (از روی نمونه): + +`prisma/khanegi-sms-targets.example.json` → مثلاً `prisma/khanegi-sms-targets.json` + +برای هر دامنه، MySQL **همان نسخه** را بگذار. پایلوت را `"pilot": true` کن. + +### ب-۳) پایلوت: dry-run سپس import + +```bash +cd /var/www/superadmin/api + +# فقط پایلوت، خشک +SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.json \ +SMS_IMPORT_ONLY_PILOT=1 \ +SMS_IMPORT_DRY_RUN=1 \ +npm run prisma:import-sms-batch + +# واقعی +SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.json \ +SMS_IMPORT_ONLY_PILOT=1 \ +npm run prisma:import-sms-batch +``` + +یا تک‌دامنه: + +```bash +bash deploy/khanegi-sms-import-one.sh DOMAIN 127.0.0.1 DB_USER 'DB_PASS' DB_NAME +bash deploy/khanegi-sms-import-one.sh DOMAIN 127.0.0.1 DB_USER 'DB_PASS' DB_NAME --apply +``` + +بعد در UI سوپرادمین موجودی/تعرفه/بلاک را با پنل خانگی قبل از روشن کردن remote چک کن. +ایمپورت موفق `tenants.sms_synced_at` را هم پر می‌کند. + +### ب-۴) روشن کردن remote فقط روی `.env` همان نسخه + +مقدار `SMS_REMOTE_SHARED_SECRET` را از `/var/www/superadmin/api/.env` بردار، بعد روی سرور خانگی: + +```bash +# مسیر server همان نسخه +bash /path/to/khanegi-sms-enable-remote.sh /var/www/CUSTOMER/server DOMAIN 'SHARED_SECRET' +# یا دستی: +# SUPERADMIN_SMS_BASE_URL=https://adm.montzar.ir/api/remote/sms +# SUPERADMIN_SMS_SHARED_SECRET=... +# SUPERADMIN_SMS_DOMAIN=DOMAIN +php artisan config:clear && php artisan config:cache +php artisan sms:superadmin-probe +``` + +### ب-۵) rollout بقیه + +همان چرخه نسخه به نسخه (یا batch بدون `ONLY_PILOT`). +`skip` / `sms_panel=me` / بدون wallet را جدا نگه دار. + +--- + +## ج) نسخه‌هایی که هنوز فعال نشده‌اند + +- جدول `sms_wallets` ندارند یا خالی‌اند → اسکریپت skip می‌کند +- `sms_panel=me` → skip +- برای آن‌ها فعلاً کاری نکن تا خودت بگویی + +--- + +## کار موازی تا اعلام تو + +تا وقتی بگویی «پنل همکار/خانگی لازم نیست»: + +- تغییر در **سوپرادمین** → با باز کردن پنل محصول هم دیده می‌شود (و در MySQL محلی هم mirror می‌شود) +- تغییر در **پنل همکار/خانگی** → در سوپرادمین ذخیره می‌شود +- ارسال SMS → کسر از سوپرادمین + mirror محلی + +بعد از اطمینان نهایی، پنل محصولات حذف می‌شود و فقط سوپرادمین می‌ماند. diff --git a/deploy/khanegi-sms-enable-remote.sh b/deploy/khanegi-sms-enable-remote.sh new file mode 100644 index 0000000..4270a56 --- /dev/null +++ b/deploy/khanegi-sms-enable-remote.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Run on the Khanegi install for ONE sold version (its own .env). +# Never point multiple installs on the same server at the same SUPERADMIN_SMS_DOMAIN. +# +# Usage: +# bash deploy/khanegi-sms-enable-remote.sh /path/to/khanegi/server DOMAIN SHARED_SECRET +set -euo pipefail + +INSTALL_SERVER_DIR="${1:-}" +DOMAIN="${2:-}" +SHARED_SECRET="${3:-}" +BASE_URL="${SUPERADMIN_SMS_BASE_URL:-https://adm.montzar.ir/api/remote/sms}" + +if [[ -z "$INSTALL_SERVER_DIR" || -z "$DOMAIN" || -z "$SHARED_SECRET" ]]; then + echo "Usage: $0 /path/to/khanegi/server DOMAIN SHARED_SECRET" >&2 + exit 1 +fi + +ENV_FILE="$INSTALL_SERVER_DIR/.env" +if [[ ! -f "$ENV_FILE" ]]; then + echo "Missing .env: $ENV_FILE" >&2 + exit 1 +fi + +backup="$ENV_FILE.bak.sms-sync.$(date +%Y%m%d%H%M%S)" +cp "$ENV_FILE" "$backup" +echo "Backup: $backup" + +tmp="$(mktemp)" +grep -vE '^(SUPERADMIN_SMS_BASE_URL|SUPERADMIN_SMS_SHARED_SECRET|SUPERADMIN_SMS_DOMAIN|SUPERADMIN_REMOTE_TOKEN)=' "$ENV_FILE" > "$tmp" || true +mv "$tmp" "$ENV_FILE" + +{ + echo "" + echo "# SuperAdmin SMS sync — per sold version (domain=$DOMAIN)" + echo "SUPERADMIN_SMS_BASE_URL=$BASE_URL" + echo "SUPERADMIN_SMS_SHARED_SECRET=$SHARED_SECRET" + echo "SUPERADMIN_SMS_DOMAIN=$DOMAIN" +} >> "$ENV_FILE" + +cd "$INSTALL_SERVER_DIR" +php artisan config:clear +php artisan config:cache || true + +if php artisan list --raw 2>/dev/null | grep -q '^sms:superadmin-probe'; then + php artisan sms:superadmin-probe +else + echo "Env written. Deploy code with sms:superadmin-probe, then run: php artisan sms:superadmin-probe" +fi + +echo "OK: remote enabled for domain=$DOMAIN in $ENV_FILE" diff --git a/deploy/khanegi-sms-import-one.sh b/deploy/khanegi-sms-import-one.sh new file mode 100644 index 0000000..cc63ed9 --- /dev/null +++ b/deploy/khanegi-sms-import-one.sh @@ -0,0 +1,49 @@ +#!/usr/bin/env bash +# Run on SuperAdmin host. Imports one Khanegi version MySQL → SuperAdmin SmsAccount. +# +# Usage: +# bash deploy/khanegi-sms-import-one.sh DOMAIN DB_HOST DB_USER DB_PASSWORD DB_NAME [--apply] +# +# Default is dry-run. Pass --apply for real write. +set -euo pipefail + +DOMAIN="${1:-}" +DB_HOST="${2:-127.0.0.1}" +DB_USER="${3:-}" +DB_PASSWORD="${4:-}" +DB_NAME="${5:-}" +MODE="${6:-}" + +if [[ -z "$DOMAIN" || -z "$DB_USER" || -z "$DB_NAME" ]]; then + echo "Usage: $0 DOMAIN DB_HOST DB_USER DB_PASSWORD DB_NAME [--apply]" >&2 + exit 1 +fi + +SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" +if [[ -d /var/www/superadmin/api ]]; then + API_DIR=/var/www/superadmin/api +elif [[ -f "$SCRIPT_DIR/../package.json" ]]; then + API_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +else + API_DIR="$(cd "$SCRIPT_DIR/../apps/api" && pwd)" +fi + +cd "$API_DIR" + +export SMS_IMPORT_DOMAIN="$DOMAIN" +export SMS_IMPORT_PRODUCT=khanegi +export SMS_IMPORT_DB_HOST="$DB_HOST" +export SMS_IMPORT_DB_PORT=3306 +export SMS_IMPORT_DB_USER="$DB_USER" +export SMS_IMPORT_DB_PASSWORD="$DB_PASSWORD" +export SMS_IMPORT_DB_DATABASE="$DB_NAME" + +if [[ "$MODE" == "--apply" ]]; then + unset SMS_IMPORT_DRY_RUN || true + echo "APPLY import for $DOMAIN" +else + export SMS_IMPORT_DRY_RUN=1 + echo "DRY-RUN import for $DOMAIN" +fi + +npm run prisma:import-sms-from-product diff --git a/deploy/run-khanegi-sms-inventory.sh b/deploy/run-khanegi-sms-inventory.sh new file mode 100644 index 0000000..45e2ca2 --- /dev/null +++ b/deploy/run-khanegi-sms-inventory.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Convenience: inventory + write JSON on SuperAdmin host. +set -euo pipefail +cd /var/www/superadmin/api +npm run prisma:inventory-khanegi-sms +SMS_INVENTORY_FORMAT=json SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json \ + npm run prisma:inventory-khanegi-sms +echo "JSON: /tmp/khanegi-sms-inventory.json" diff --git a/package.json b/package.json index 557c77d..d1333a3 100644 --- a/package.json +++ b/package.json @@ -28,6 +28,8 @@ "prisma:seed-khanegi-defaults": "ts-node --transpile-only prisma/seed-khanegi-default-snapshots.ts", "prisma:ensure-pilot-sms-tokens": "ts-node --transpile-only prisma/ensure-pilot-sms-tokens.ts", "prisma:import-sms-from-product": "ts-node --transpile-only prisma/import-sms-from-product.ts", + "prisma:import-sms-batch": "ts-node --transpile-only prisma/import-sms-batch.ts", + "prisma:inventory-khanegi-sms": "ts-node --transpile-only prisma/inventory-khanegi-sms.ts", "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" }, diff --git a/prisma/import-sms-batch.ts b/prisma/import-sms-batch.ts new file mode 100644 index 0000000..dcf0ee4 --- /dev/null +++ b/prisma/import-sms-batch.ts @@ -0,0 +1,138 @@ +/** + * Batch-import SMS wallets for many sold versions from a targets JSON file. + * + * Each target is one Khanegi/Hamkar MySQL (one sold version). Never share one + * DB/env across multiple domains on the same host. + * + * SMS_IMPORT_TARGETS=./prisma/khanegi-sms-targets.example.json \ + * SMS_IMPORT_DRY_RUN=1 \ + * npm run prisma:import-sms-batch + * + * Target shape: + * [ + * { + * "domain": "example.ir", + * "product": "khanegi", + * "pilot": true, + * "db": { "host": "127.0.0.1", "port": 3306, "user": "u", "password": "p", "database": "db" } + * } + * ] + */ +import * as fs from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; + +type DbTarget = { + host?: string; + port?: number | string; + user?: string; + password?: string; + database: string; +}; + +type ImportTarget = { + domain: string; + product?: 'khanegi' | 'hamkar'; + pilot?: boolean; + skip?: boolean; + db: DbTarget; +}; + +function env(name: string, fallback = '') { + return String(process.env[name] ?? fallback).trim(); +} + +function loadTargets(filePath: string): ImportTarget[] { + const abs = path.resolve(filePath); + if (!fs.existsSync(abs)) { + throw new Error(`Targets file not found: ${abs}`); + } + const raw = JSON.parse(fs.readFileSync(abs, 'utf8')) as ImportTarget[]; + if (!Array.isArray(raw)) throw new Error('Targets JSON must be an array'); + return raw; +} + +function main() { + const targetsPath = + env('SMS_IMPORT_TARGETS') || + path.join(__dirname, 'khanegi-sms-targets.example.json'); + const dryRun = env('SMS_IMPORT_DRY_RUN') === '1'; + const onlyPilot = env('SMS_IMPORT_ONLY_PILOT') === '1'; + const onlyDomain = env('SMS_IMPORT_DOMAIN'); + + let targets = loadTargets(targetsPath).filter((t) => !t.skip); + if (onlyPilot) targets = targets.filter((t) => t.pilot); + if (onlyDomain) { + targets = targets.filter( + (t) => t.domain.toLowerCase() === onlyDomain.toLowerCase(), + ); + } + + if (!targets.length) { + console.error('No targets to import.'); + process.exitCode = 1; + return; + } + + console.log( + JSON.stringify( + { + targetsPath, + dryRun, + onlyPilot, + onlyDomain: onlyDomain || null, + count: targets.length, + domains: targets.map((t) => t.domain), + }, + null, + 2, + ), + ); + + const script = path.join(__dirname, 'import-sms-from-product.ts'); + let failed = 0; + + for (const target of targets) { + const product = target.product || 'khanegi'; + const db = target.db || ({} as DbTarget); + if (!db.database) { + console.error(`SKIP ${target.domain}: missing db.database`); + failed += 1; + continue; + } + + const childEnv = { + ...process.env, + SMS_IMPORT_DOMAIN: target.domain, + SMS_IMPORT_PRODUCT: product, + SMS_IMPORT_DRY_RUN: dryRun ? '1' : env('SMS_IMPORT_DRY_RUN'), + SMS_IMPORT_DB_HOST: String(db.host || '127.0.0.1'), + SMS_IMPORT_DB_PORT: String(db.port || 3306), + SMS_IMPORT_DB_USER: String(db.user || 'root'), + SMS_IMPORT_DB_PASSWORD: String(db.password ?? ''), + SMS_IMPORT_DB_DATABASE: String(db.database), + }; + + console.log(`\n=== ${dryRun ? 'DRY ' : ''}IMPORT ${target.domain} (${product}) ===`); + const result = spawnSync( + process.platform === 'win32' ? 'npx.cmd' : 'npx', + ['ts-node', '--transpile-only', script], + { + env: childEnv, + cwd: path.join(__dirname, '..'), + stdio: 'inherit', + shell: process.platform === 'win32', + }, + ); + + if (result.status !== 0) { + failed += 1; + console.error(`FAILED: ${target.domain} (exit ${result.status})`); + } + } + + console.log(`\nDone. failed=${failed} / total=${targets.length}`); + if (failed) process.exitCode = 1; +} + +main(); diff --git a/prisma/import-sms-from-product.ts b/prisma/import-sms-from-product.ts index a512b79..7c648c3 100644 --- a/prisma/import-sms-from-product.ts +++ b/prisma/import-sms-from-product.ts @@ -366,6 +366,11 @@ async function main() { } } + await prisma.tenant.update({ + where: { id: tenant.id }, + data: { smsSyncedAt: new Date() }, + }); + await prisma.auditLog.create({ data: { action: 'sms.import.from_product', @@ -381,6 +386,7 @@ async function main() { ledgerCount: ledgers.length, sendLogCount: sendLogs.length, mysql: { host, database }, + smsSyncedAt: true, }, }, }); @@ -390,6 +396,7 @@ async function main() { balanceRials: payload.balanceRials, ledger: ledgers.length, sendLogs: sendLogs.length, + smsSyncedAt: true, }); } finally { await conn.end(); diff --git a/prisma/inventory-khanegi-sms.ts b/prisma/inventory-khanegi-sms.ts new file mode 100644 index 0000000..094066e --- /dev/null +++ b/prisma/inventory-khanegi-sms.ts @@ -0,0 +1,200 @@ +/** + * Inventory Khanegi tenants for SMS sync rollout. + * + * Groups by nodeServer host (multiple versions may share one physical server). + * Prints TSV + summary JSON. Use on the SuperAdmin host: + * + * npm run prisma:inventory-khanegi-sms + * SMS_INVENTORY_FORMAT=json npm run prisma:inventory-khanegi-sms + * SMS_INVENTORY_OUT=/tmp/khanegi-sms-inventory.json npm run prisma:inventory-khanegi-sms + */ +import * as fs from 'fs'; +import { PrismaClient, ProductType } from '@prisma/client'; + +const prisma = new PrismaClient(); + +function env(name: string, fallback = '') { + return String(process.env[name] ?? fallback).trim(); +} + +function hostFromUrl(url?: string | null) { + if (!url) return '(unknown-host)'; + return ( + String(url) + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .split('/')[0] + .split(':')[0] || '(unknown-host)' + ); +} + +type SyncStatus = + | 'no_sms_account' + | 'imported' + | 'imported_marked' + | 'empty_default' + | 'inactive'; + +function classify(row: { + isActive: boolean; + hasAccount: boolean; + smsSyncedAt: Date | null; + balanceRials: number | null; + ledgerCount: number; + sendLogCount: number; +}): SyncStatus { + if (!row.isActive) return 'inactive'; + if (!row.hasAccount) return 'no_sms_account'; + if (row.smsSyncedAt) return 'imported_marked'; + if ( + (row.balanceRials ?? 0) === 0 && + row.ledgerCount === 0 && + row.sendLogCount === 0 + ) { + return 'empty_default'; + } + return 'imported'; +} + +async function main() { + const format = (env('SMS_INVENTORY_FORMAT', 'tsv') || 'tsv').toLowerCase(); + const outPath = env('SMS_INVENTORY_OUT'); + + const tenants = await prisma.tenant.findMany({ + where: { product: ProductType.khanegi }, + orderBy: [{ nodeServer: 'asc' }, { domain: 'asc' }], + include: { + smsAccount: { + include: { + _count: { select: { ledgers: true, sendLogs: true } }, + }, + }, + }, + }); + + const rows = tenants.map((t) => { + const acc = t.smsAccount; + const ledgerCount = acc?._count.ledgers ?? 0; + const sendLogCount = acc?._count.sendLogs ?? 0; + const balanceRials = acc?.balanceRials ?? null; + const status = classify({ + isActive: t.isActive, + hasAccount: !!acc, + smsSyncedAt: t.smsSyncedAt, + balanceRials, + ledgerCount, + sendLogCount, + }); + + return { + id: t.id, + nameFa: t.nameFa, + domain: t.domain, + domains: t.domains, + appUrl: t.appUrl, + adminUrl: t.adminUrl, + nodeServer: t.nodeServer, + serverHost: hostFromUrl(t.nodeServer), + isActive: t.isActive, + smsSyncedAt: t.smsSyncedAt?.toISOString() ?? null, + hasSmsAccount: !!acc, + balanceRials, + pricePerPartRials: acc?.pricePerPartRials ?? null, + isBlocked: acc?.isBlocked ?? null, + autoBlockBelowRials: acc?.autoBlockBelowRials ?? null, + ledgerCount, + sendLogCount, + syncStatus: status, + nextAction: + status === 'inactive' + ? 'skip_inactive' + : status === 'no_sms_account' || status === 'empty_default' + ? 'import_then_enable_remote' + : status === 'imported' || status === 'imported_marked' + ? 'enable_remote_if_not_yet' + : 'review', + }; + }); + + const byHost = new Map(); + for (const row of rows) { + const list = byHost.get(row.serverHost) || []; + list.push(row); + byHost.set(row.serverHost, list); + } + + const summary = { + total: rows.length, + byStatus: rows.reduce>((acc, r) => { + acc[r.syncStatus] = (acc[r.syncStatus] || 0) + 1; + return acc; + }, {}), + multiVersionHosts: [...byHost.entries()] + .filter(([, list]) => list.length > 1) + .map(([host, list]) => ({ + host, + count: list.length, + domains: list.map((r) => r.domain), + })), + generatedAt: new Date().toISOString(), + }; + + const payload = { summary, rows }; + + if (outPath) { + fs.writeFileSync(outPath, JSON.stringify(payload, null, 2), 'utf8'); + console.error(`Wrote ${outPath}`); + } + + if (format === 'json') { + console.log(JSON.stringify(payload, null, 2)); + } else { + console.log( + [ + 'serverHost', + 'domain', + 'nameFa', + 'syncStatus', + 'nextAction', + 'balanceRials', + 'pricePerPartRials', + 'isBlocked', + 'smsSyncedAt', + 'ledgerCount', + 'sendLogCount', + 'appUrl', + 'nodeServer', + ].join('\t'), + ); + for (const r of rows) { + console.log( + [ + r.serverHost, + r.domain, + r.nameFa, + r.syncStatus, + r.nextAction, + r.balanceRials ?? '', + r.pricePerPartRials ?? '', + r.isBlocked ?? '', + r.smsSyncedAt ?? '', + r.ledgerCount, + r.sendLogCount, + r.appUrl ?? '', + r.nodeServer, + ].join('\t'), + ); + } + console.error(JSON.stringify(summary, null, 2)); + } +} + +main() + .catch((e) => { + console.error(e); + process.exitCode = 1; + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/prisma/khanegi-sms-targets.example.json b/prisma/khanegi-sms-targets.example.json new file mode 100644 index 0000000..dfe41ca --- /dev/null +++ b/prisma/khanegi-sms-targets.example.json @@ -0,0 +1,28 @@ +[ + { + "domain": "k.montzar.ir", + "product": "khanegi", + "pilot": true, + "db": { + "host": "127.0.0.1", + "port": 3306, + "user": "REPLACE_USER", + "password": "REPLACE_PASSWORD", + "database": "REPLACE_DATABASE" + }, + "notes": "Pilot first. Put real MySQL of THIS version only — never a shared DB for all sites on the host." + }, + { + "domain": "example-customer.ir", + "product": "khanegi", + "pilot": false, + "skip": true, + "db": { + "host": "127.0.0.1", + "port": 3306, + "user": "REPLACE_USER", + "password": "REPLACE_PASSWORD", + "database": "REPLACE_DATABASE" + } + } +] diff --git a/prisma/list-tenants.ts b/prisma/list-tenants.ts index 3fb5cc6..d24d167 100644 --- a/prisma/list-tenants.ts +++ b/prisma/list-tenants.ts @@ -36,8 +36,11 @@ async function main() { domains: t.domains, appUrl: t.appUrl, nodeServer: t.nodeServer, + smsSyncedAt: t.smsSyncedAt, balanceRials: t.smsAccount?.balanceRials ?? null, pricePerPartRials: t.smsAccount?.pricePerPartRials ?? null, + isBlocked: t.smsAccount?.isBlocked ?? null, + autoBlockBelowRials: t.smsAccount?.autoBlockBelowRials ?? null, }, null, 2,