From a6b34577790880479439ea2c758d36dd93b6e50c Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 30 Jul 2026 18:19:53 +0330 Subject: [PATCH] Add production-ready SuperAdmin API features for tenants, SMS, servers, and plugins. Includes mobile auth, get_user/get_version, SMS wallet parity endpoints, appearance backfill, and related Prisma migrations. Co-authored-by: Cursor --- .env.example | 18 +- .gitignore | 6 + package-lock.json | 11 + package.json | 4 +- prisma/backfill-app-version.js | 33 ++ prisma/backfill-appearance-from-registry.ts | 272 +++++++++ prisma/import-users.ts | 1 + prisma/lib/appearance-builders.ts | 165 ++++++ .../migration.sql | 12 + .../migration.sql | 5 + .../migration.sql | 49 ++ .../migration.sql | 47 ++ .../migration.sql | 47 ++ .../migration.sql | 36 ++ prisma/schema.prisma | 157 ++++- prisma/seed.ts | 94 +-- src/admins/admins.controller.ts | 55 ++ src/admins/admins.module.ts | 12 + src/admins/admins.service.ts | 222 +++++++ src/admins/dto/admin.dto.ts | 66 +++ src/app.module.ts | 10 + src/audit/audit.service.ts | 45 +- src/auth/auth.service.ts | 32 +- src/auth/dto/login.dto.ts | 4 +- src/auth/jwt.strategy.ts | 13 +- src/common/iran-time.util.ts | 40 ++ src/common/mobile.util.ts | 19 + src/get-user/get-user.service.ts | 63 +- src/get-version/get-version.controller.ts | 19 + src/get-version/get-version.module.ts | 9 + src/get-version/get-version.service.ts | 71 +++ src/main.ts | 49 +- src/notifications/admin-notify.service.ts | 48 ++ src/notifications/central-sms.service.ts | 83 +++ src/notifications/dto/update-admin-sms.dto.ts | 6 + src/notifications/notifications.module.ts | 12 + .../runtime-settings.controller.ts | 21 + src/notifications/runtime-settings.service.ts | 62 ++ src/plugins/dto/plugin.dto.ts | 121 ++++ src/plugins/plugins.controller.ts | 77 +++ src/plugins/plugins.module.ts | 12 + src/plugins/plugins.service.ts | 557 ++++++++++++++++++ src/servers/dto/server-account.dto.ts | 81 +++ src/servers/servers.controller.ts | 58 ++ src/servers/servers.module.ts | 12 + src/servers/servers.service.ts | 505 ++++++++++++++++ src/sms/dto/sms-account.dto.ts | 56 +- src/sms/sms.controller.ts | 61 +- src/sms/sms.module.ts | 2 + src/sms/sms.service.ts | 377 ++++++++++-- src/tenants/dto/create-tenant.dto.ts | 5 + src/tenants/tenants.controller.ts | 51 +- src/tenants/tenants.module.ts | 2 + src/tenants/tenants.service.ts | 31 + 54 files changed, 3770 insertions(+), 156 deletions(-) create mode 100644 prisma/backfill-app-version.js create mode 100644 prisma/backfill-appearance-from-registry.ts create mode 100644 prisma/lib/appearance-builders.ts create mode 100644 prisma/migrations/20260727120000_product_releases/migration.sql create mode 100644 prisma/migrations/20260727140000_tenant_app_version/migration.sql create mode 100644 prisma/migrations/20260727150000_users_mobile_admins/migration.sql create mode 100644 prisma/migrations/20260727160000_server_accounts/migration.sql create mode 100644 prisma/migrations/20260728120000_plugin_subscriptions/migration.sql create mode 100644 prisma/migrations/20260729150000_sms_panel_parity/migration.sql create mode 100644 src/admins/admins.controller.ts create mode 100644 src/admins/admins.module.ts create mode 100644 src/admins/admins.service.ts create mode 100644 src/admins/dto/admin.dto.ts create mode 100644 src/common/iran-time.util.ts create mode 100644 src/common/mobile.util.ts create mode 100644 src/get-version/get-version.controller.ts create mode 100644 src/get-version/get-version.module.ts create mode 100644 src/get-version/get-version.service.ts create mode 100644 src/notifications/admin-notify.service.ts create mode 100644 src/notifications/central-sms.service.ts create mode 100644 src/notifications/dto/update-admin-sms.dto.ts create mode 100644 src/notifications/notifications.module.ts create mode 100644 src/notifications/runtime-settings.controller.ts create mode 100644 src/notifications/runtime-settings.service.ts create mode 100644 src/plugins/dto/plugin.dto.ts create mode 100644 src/plugins/plugins.controller.ts create mode 100644 src/plugins/plugins.module.ts create mode 100644 src/plugins/plugins.service.ts create mode 100644 src/servers/dto/server-account.dto.ts create mode 100644 src/servers/servers.controller.ts create mode 100644 src/servers/servers.module.ts create mode 100644 src/servers/servers.service.ts diff --git a/.env.example b/.env.example index 649f11c..aa27b50 100644 --- a/.env.example +++ b/.env.example @@ -2,10 +2,22 @@ PORT=3001 DATABASE_URL="file:./dev.db" JWT_SECRET="change-me-in-production-use-long-random-string" JWT_EXPIRES_IN="7d" -CORS_ORIGIN="http://localhost:5173" -SEED_ADMIN_EMAIL="admin@abshode.local" +CORS_ORIGIN="http://localhost:5173,http://localhost:3100" +# آدرس عمومی API برای ساخت URL لوگو (در پروداکشن دامنه HTTPS سوپرادمین) +PUBLIC_API_URL="http://localhost:3001" + +# مدیر پیش‌فرض (لاگین با موبایل) +SEED_ADMIN_MOBILE="09128081392" SEED_ADMIN_PASSWORD="Admin@12345" -SEED_ADMIN_NAME="Super Admin" +SEED_ADMIN_FIRST_NAME="مدیر" +SEED_ADMIN_LAST_NAME="اصلی" +SEED_ADMIN_EMAIL="admin@abshode.local" + +# اس‌ام‌اس رایگان مرکزی (همان createsms همکار/خانگی) — secret را فقط در .env واقعی بگذار +CENTRAL_SMS_URL="http://185.231.112.101/api/createsms" +CENTRAL_SMS_SECRET="replace-with-central-sms-secret" +CENTRAL_SMS_HOST="superadmin" +CENTRAL_SMS_PLATFORM="superadmin" HAMKAR_DB_HOST=127.0.0.1 HAMKAR_DB_PORT=3306 diff --git a/.gitignore b/.gitignore index 0888321..1490f2f 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,12 @@ prisma/dev.db prisma/dev.db-journal *.db +# local runtime toggles +data/ + +# uploaded tenant assets +uploads/ + # logs & ide *.log .DS_Store diff --git a/package-lock.json b/package-lock.json index 5bdbf49..9ba8496 100644 --- a/package-lock.json +++ b/package-lock.json @@ -36,6 +36,7 @@ "@types/bcrypt": "5.0.2", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.2.0", "@types/node": "^22.0.0", "@types/passport-jwt": "4.0.1", "@types/supertest": "^7.0.0", @@ -3047,6 +3048,16 @@ "integrity": "sha512-GsCCIZDE/p3i96vtEqx+7dBUGXrc7zeSK3wwPHIaRThS+9OhWIXRqzs4d6k1SVU8g91DrNRWxWUGhp5KXQb2VA==", "license": "MIT" }, + "node_modules/@types/multer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/@types/multer/-/multer-2.2.0.tgz", + "integrity": "sha512-3U1troeqGV8Ntp7Q3klwf4zr23VEoqYVocYXaswm9+8z3O9UHDYAqLxjJ/h550iRADTjKdOdhhasXw6gD6kYtg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/express": "*" + } + }, "node_modules/@types/node": { "version": "22.20.1", "resolved": "https://registry.npmmirror.com/@types/node/-/node-22.20.1.tgz", diff --git a/package.json b/package.json index f56a6ff..7137214 100644 --- a/package.json +++ b/package.json @@ -22,7 +22,8 @@ "prisma:migrate": "prisma migrate dev", "prisma:deploy": "prisma migrate deploy", "prisma:seed": "ts-node --transpile-only prisma/seed.ts", - "prisma:import-users": "ts-node --transpile-only prisma/import-users.ts" + "prisma:import-users": "ts-node --transpile-only prisma/import-users.ts", + "prisma:backfill-appearance": "ts-node --transpile-only prisma/backfill-appearance-from-registry.ts" }, "prisma": { "seed": "ts-node --transpile-only prisma/seed.ts" @@ -55,6 +56,7 @@ "@types/bcrypt": "5.0.2", "@types/express": "^5.0.0", "@types/jest": "^30.0.0", + "@types/multer": "^2.2.0", "@types/node": "^22.0.0", "@types/passport-jwt": "4.0.1", "@types/supertest": "^7.0.0", diff --git a/prisma/backfill-app-version.js b/prisma/backfill-app-version.js new file mode 100644 index 0000000..ec78148 --- /dev/null +++ b/prisma/backfill-app-version.js @@ -0,0 +1,33 @@ +const { PrismaClient } = require('@prisma/client'); + +const prisma = new PrismaClient(); + +async function main() { + const rows = await prisma.tenant.findMany({ + select: { id: true, notes: true, appVersion: true }, + }); + + let n = 0; + for (const row of rows) { + const match = String(row.notes || '').match(/version:([^|\s]+)/i); + if (!match?.[1]) continue; + if (row.appVersion && row.appVersion !== '1') continue; + + await prisma.tenant.update({ + where: { id: row.id }, + data: { appVersion: match[1].trim() }, + }); + n += 1; + } + + console.log(`backfilled ${n} of ${rows.length}`); +} + +main() + .catch((error) => { + console.error(error); + process.exit(1); + }) + .finally(async () => { + await prisma.$disconnect(); + }); diff --git a/prisma/backfill-appearance-from-registry.ts b/prisma/backfill-appearance-from-registry.ts new file mode 100644 index 0000000..76d09c8 --- /dev/null +++ b/prisma/backfill-appearance-from-registry.ts @@ -0,0 +1,272 @@ +/** + * Backfill SuperAdmin tenant appearance from live registries (by domain Origin) + * and users.json fallback. Preserves named CSS modes (e.g. limangold). + * + * Usage: + * npm run prisma:backfill-appearance + */ +import * as fs from 'fs' +import * as path from 'path' +import { Prisma, PrismaClient } from '@prisma/client' +import { + buildHamkarUiFromBrand, + buildKhanegiUiFromColors, + pickRegistryPayload, + resolveAvatarUrl, + type RegistryUser, +} from './lib/appearance-builders' + +const prisma = new PrismaClient() + +const REGISTRY_URLS = [ + 'https://nerkh.gold/get_user', + 'https://ah.aygingold.ir/get_user', + 'https://tablo.talasevin.ir/get_user', + 'https://abshode-service.montgit.ir/server/get_user', +] + +type JsonUser = { + id: number + name?: string + name_fa?: string + avatar?: string | null + domains?: string | string[] | null + color?: string | null + secondary_color?: string | null + background_color?: string | null + mode?: string | null + app_ui_config?: unknown + deleted_at?: string | null + node_server?: string | null +} + +function normalizeHost(value: string) { + return value + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .split('/')[0] + .split(':')[0] +} + +function parseDomains(raw: unknown): string[] { + if (!raw) return [] + if (Array.isArray(raw)) return raw.map(String).map(normalizeHost).filter(Boolean) + if (typeof raw === 'string') { + try { + const parsed = JSON.parse(raw) + if (Array.isArray(parsed)) return parsed.map(String).map(normalizeHost).filter(Boolean) + } catch { + return [] + } + } + return [] +} + +function readTenantHosts(tenant: { domain: string; domains: unknown; appUrl?: string | null }) { + const hosts = new Set() + hosts.add(normalizeHost(tenant.domain)) + for (const d of parseDomains(tenant.domains)) hosts.add(d) + if (tenant.appUrl) hosts.add(normalizeHost(tenant.appUrl)) + return [...hosts].filter(Boolean) +} + +function loadUsersJson(): JsonUser[] { + const file = path.join(__dirname, 'data', 'users.json') + if (!fs.existsSync(file)) return [] + try { + return JSON.parse(fs.readFileSync(file, 'utf8')) as JsonUser[] + } catch { + return [] + } +} + +function findJsonUser(tenant: { domain: string; domains: unknown; notes?: string | null; slug: string }, rows: JsonUser[]) { + const hosts = new Set(readTenantHosts(tenant)) + const external = tenant.notes?.match(/externalId:(\d+)/)?.[1] + + for (const row of rows) { + if (row.deleted_at) continue + if (external && String(row.id) === external) return row + const rowHosts = parseDomains(row.domains) + if (rowHosts.some((h) => hosts.has(h))) return row + if (row.name && normalizeHost(row.name) === normalizeHost(tenant.slug)) return row + } + return null +} + +async function fetchRegistryByOrigin(domain: string): Promise<{ data: RegistryUser; base: string } | null> { + const origin = `https://${domain}` + for (const base of REGISTRY_URLS) { + const controller = new AbortController() + const timer = setTimeout(() => controller.abort(), 8000) + try { + const res = await fetch(base, { + headers: { + Accept: 'application/json', + Origin: origin, + Referer: `${origin}/`, + }, + signal: controller.signal, + }) + if (!res.ok) continue + const json = await res.json() + const data = pickRegistryPayload(json) + // Reject empty / unrelated responses: must mention domain in node_server/domains-ish or have name + const nodeHost = normalizeHost(String(data.node_server || '')) + const related = + nodeHost.includes(domain.replace(/^www\./, '')) || + domain.includes(normalizeHost(String(data.name || ''))) || + Boolean(data.avatar || data.color || data.secondary_color || data.app_ui_config) + if (!related && !data.name) continue + return { data, base } + } catch { + /* next */ + } finally { + clearTimeout(timer) + } + } + return null +} + +function jsonToRegistry(row: JsonUser): RegistryUser { + return { + avatar: row.avatar, + color: row.color, + secondary_color: row.secondary_color, + background_color: row.background_color, + name: row.name, + name_fa: row.name_fa, + mode: row.mode, + theme: row.mode, + app_ui_config: row.app_ui_config, + } +} + +async function main() { + const jsonRows = loadUsersJson() + const tenants = await prisma.tenant.findMany({ + where: { isActive: true }, + orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], + }) + + const summary = { + total: tenants.length, + updated: 0, + fromLive: 0, + fromJson: 0, + skipped: 0, + hamkar: 0, + khanegi: 0, + namedModes: 0, + } + + for (const tenant of tenants) { + const hosts = readTenantHosts(tenant) + let hit: { data: RegistryUser; base: string; source: 'live' | 'json' } | null = null + + for (const host of hosts) { + const live = await fetchRegistryByOrigin(host) + if (live) { + hit = { ...live, source: 'live' } + break + } + } + + if (!hit) { + const row = findJsonUser(tenant, jsonRows) + if (row) { + hit = { + data: jsonToRegistry(row), + base: 'https://nerkh.gold/get_user', + source: 'json', + } + } + } + + if (!hit) { + summary.skipped += 1 + console.warn(`SKIP ${tenant.product} ${tenant.domain}`) + continue + } + + const { data, base, source } = hit + if (source === 'live') summary.fromLive += 1 + else summary.fromJson += 1 + + // Prefer live/json values only — never keep invented SA defaults like #1d4ed8 + const primary = data.color ?? null + const secondary = data.secondary_color ?? null + const background = data.background_color ?? null + const avatar = resolveAvatarUrl(data.avatar, base) || tenant.avatarUrl + const registryMode = String(data.mode || data.theme || '').trim() || null + if (registryMode && registryMode !== 'dynamicMode') summary.namedModes += 1 + + const existingUi = (tenant.appUiConfig as Record | null) || null + let appUiConfig: Prisma.InputJsonValue + + if (tenant.product === 'hamkar') { + const managed = existingUi?.kind === 'hamkar' && existingUi?.settingsManaged === true + const built = buildHamkarUiFromBrand({ + primaryColor: primary, + secondaryColor: secondary, + backgroundColor: background, + settingsManaged: managed, + registryMode: registryMode || existingUi?.registryMode || 'dynamicMode', + settings: managed ? existingUi.settings : undefined, + }) + appUiConfig = built as unknown as Prisma.InputJsonValue + summary.hamkar += 1 + } else { + const built = buildKhanegiUiFromColors({ + primary: primary || tenant.primaryColor, + secondary: secondary || tenant.secondaryColor, + background: background || tenant.backgroundColor, + existing: + data.app_ui_config || + (existingUi?.schemaId === 'ui-config' ? existingUi : null), + }) + appUiConfig = { + ...(built as object), + registryMode: registryMode || existingUi?.registryMode || null, + } as unknown as Prisma.InputJsonValue + summary.khanegi += 1 + } + + const notes = [ + tenant.notes || '', + `appearance:backfill-${source}`, + registryMode ? `registryMode:${registryMode}` : '', + ] + .filter(Boolean) + .join(' | ') + + await prisma.tenant.update({ + where: { id: tenant.id }, + data: { + avatarUrl: avatar, + primaryColor: primary, + secondaryColor: secondary, + backgroundColor: background, + appUiConfig, + notes, + }, + }) + + summary.updated += 1 + console.log( + `OK ${tenant.product} ${tenant.domain} ← ${source}${registryMode ? ` mode=${registryMode}` : ''} color=${primary || '-'}`, + ) + } + + console.log('\nSummary:', summary) +} + +main() + .catch((error) => { + console.error(error) + process.exitCode = 1 + }) + .finally(async () => { + await prisma.$disconnect() + }) diff --git a/prisma/import-users.ts b/prisma/import-users.ts index 4138c64..c1b61db 100644 --- a/prisma/import-users.ts +++ b/prisma/import-users.ts @@ -283,6 +283,7 @@ async function main() { domainOverride?.appUrl || domains.find((d) => !isIp(d)) || domain, adminUrl: null as string | null, appUiConfig: ui, + appVersion: String(row.version || '1').trim() || '1', isActive: true, notes, }; diff --git a/prisma/lib/appearance-builders.ts b/prisma/lib/appearance-builders.ts new file mode 100644 index 0000000..72280e0 --- /dev/null +++ b/prisma/lib/appearance-builders.ts @@ -0,0 +1,165 @@ +/** Shared appearance helpers for import/backfill scripts (no Vue deps). */ + +export type RegistryUser = { + avatar?: string | null + color?: string | null + secondary_color?: string | null + background_color?: string | null + name?: string | null + name_fa?: string | null + theme?: string | null + mode?: string | null + node_server?: string | null + app_ui_config?: unknown + user?: RegistryUser +} + +export function pickRegistryPayload(raw: any): RegistryUser { + if (raw?.user && typeof raw.user === 'object') { + return { ...raw, ...raw.user } + } + return raw || {} +} + +export function resolveAvatarUrl(raw: string | null | undefined, registryBase: string) { + if (!raw) return null + if (/^https?:\/\//i.test(raw)) return raw + if (/^data:/i.test(raw)) return raw + const base = registryBase.replace(/\/get_user\/?$/i, '').replace(/\/+$/, '') + return `${base}/${String(raw).replace(/^\/+/, '')}` +} + +export function buildHamkarUiFromBrand(input: { + primaryColor?: string | null + secondaryColor?: string | null + backgroundColor?: string | null + settingsManaged?: boolean + registryMode?: string | null + settings?: { + pricing_box_theme?: string + price_font_size?: string + orderUI?: string + } +}) { + return { + kind: 'hamkar' as const, + settingsManaged: Boolean(input.settingsManaged), + registryMode: input.registryMode || 'dynamicMode', + brand: { + // Keep nulls — do NOT invent #1d4ed8 (causes wrong accent vs live registry) + primaryColor: input.primaryColor || null, + secondaryColor: input.secondaryColor || null, + backgroundColor: input.backgroundColor || null, + }, + settings: { + pricing_box_theme: String(input.settings?.pricing_box_theme || '1'), + price_font_size: String(input.settings?.price_font_size || 'بزرگ'), + orderUI: String(input.settings?.orderUI || 'old'), + }, + } +} + +export function buildKhanegiUiFromColors(input: { + primary?: string | null + secondary?: string | null + background?: string | null + existing?: any +}) { + if ( + input.existing && + typeof input.existing === 'object' && + (input.existing.schemaId === 'ui-config' || input.existing.app) + ) { + return input.existing + } + + const primary = input.primary || '#0A1B37' + const secondary = input.secondary || '#e3b056' + const pageBg = input.background || '#121216' + const surface = '#222228' + + const dark = { + primary, + secondary, + tertiary: secondary, + background: '#ffffff', + surface, + surfaceHover: '#2c2c34', + textPrimary: '#ffffff', + textSecondary: 'rgba(255, 255, 255, 0.45)', + textSubtle: 'rgba(255, 255, 255, 0.35)', + border: 'rgba(255, 255, 255, 0.16)', + borderSubtle: 'rgba(255, 255, 255, 0.12)', + pageBg, + pageBgMobile: pageBg, + onAccent: '#0a0a0a', + success: '#16A34A', + warning: '#F59E0B', + danger: '#DC2626', + } + + const light = { + ...dark, + surface: '#ffffff', + surfaceHover: '#fffefb', + textPrimary: '#1c1916', + textSecondary: '#5c564d', + textSubtle: '#8a8278', + border: 'rgba(0, 0, 0, 0.08)', + borderSubtle: 'rgba(0, 0, 0, 0.06)', + pageBg: pageBg === '#121216' ? '#f5f2ed' : pageBg, + pageBgMobile: pageBg === '#121216' ? '#faf8f5' : pageBg, + onAccent: '#ffffff', + } + + return { + schemaId: 'ui-config', + version: '1.0.0', + app: { + schemaId: 'app', + version: '1.0.0', + direction: 'rtl', + locale: 'fa-IR', + startRoute: 'home', + theme: { + presetId: 'custom', + mode: 'dark', + colors: { ...dark }, + schemes: { dark: { ...dark }, light: { ...light } }, + borderRadius: { sm: 8, md: 12, lg: 16 }, + }, + navigation: { + type: 'sidebar+bottom', + sidebar: [ + { id: 'home', label: 'داشبورد', icon: 'vuesax-linear:chart', route: 'home', order: 1, visible: true }, + { id: 'products', label: 'معاملات', icon: 'vuesax-linear:shop', route: 'products', order: 2, visible: true }, + { id: 'transactions', label: 'تراکنش‌ها', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true }, + { id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true }, + { id: 'user', label: 'حساب کاربری', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true }, + ], + bottom: [ + { id: 'home', label: 'خانه', icon: 'vuesax-linear:home-2', route: 'home', order: 1, visible: true }, + { id: 'products', label: 'معامله', icon: 'vuesax-linear:shop', route: 'products', order: 2, visible: true }, + { id: 'transactions', label: 'سوابق', icon: 'vuesax-linear:receipt-item', route: 'transactions', order: 3, visible: true }, + { id: 'wallet', label: 'کیف پول', icon: 'vuesax-linear:wallet-2', route: 'wallet', order: 4, visible: true }, + { id: 'user', label: 'پروفایل', icon: 'vuesax-linear:user', route: 'user', order: 5, visible: true }, + ], + sidebarReferral: { + visible: true, + title: 'دعوت از دوستان', + subtitle: 'کسب درآمد', + route: 'earnMoney', + }, + }, + header: { + visible: true, + showLogo: true, + showNotifications: true, + showProfile: true, + brandSubtitle: 'پنل کاربری', + }, + }, + pages: {}, + _backfill: 'colors-from-live-registry', + } +} diff --git a/prisma/migrations/20260727120000_product_releases/migration.sql b/prisma/migrations/20260727120000_product_releases/migration.sql new file mode 100644 index 0000000..bbdeb47 --- /dev/null +++ b/prisma/migrations/20260727120000_product_releases/migration.sql @@ -0,0 +1,12 @@ +-- CreateTable +CREATE TABLE "product_releases" ( + "id" TEXT NOT NULL PRIMARY KEY, + "product" TEXT NOT NULL, + "version" TEXT NOT NULL, + "note" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "product_releases_product_key" ON "product_releases"("product"); diff --git a/prisma/migrations/20260727140000_tenant_app_version/migration.sql b/prisma/migrations/20260727140000_tenant_app_version/migration.sql new file mode 100644 index 0000000..30e144b --- /dev/null +++ b/prisma/migrations/20260727140000_tenant_app_version/migration.sql @@ -0,0 +1,5 @@ +-- AlterTable +ALTER TABLE "tenants" ADD COLUMN "app_version" TEXT NOT NULL DEFAULT '1'; + +-- DropTable +DROP TABLE IF EXISTS "product_releases"; diff --git a/prisma/migrations/20260727150000_users_mobile_admins/migration.sql b/prisma/migrations/20260727150000_users_mobile_admins/migration.sql new file mode 100644 index 0000000..c467df9 --- /dev/null +++ b/prisma/migrations/20260727150000_users_mobile_admins/migration.sql @@ -0,0 +1,49 @@ +-- Rebuild users for mobile login (SQLite) +PRAGMA foreign_keys=OFF; + +CREATE TABLE "users_new" ( + "id" TEXT NOT NULL PRIMARY KEY, + "mobile" TEXT NOT NULL, + "first_name" TEXT NOT NULL, + "last_name" TEXT NOT NULL, + "email" TEXT, + "password_hash" TEXT NOT NULL, + "role" TEXT NOT NULL DEFAULT 'super_admin', + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL +); + +INSERT INTO "users_new" ( + "id", "mobile", "first_name", "last_name", "email", + "password_hash", "role", "is_active", "created_at", "updated_at" +) +SELECT + "id", + CASE + WHEN "email" = 'admin@abshode.local' THEN '09128081392' + ELSE printf('09%09d', abs(random()) % 1000000000) + END, + CASE + WHEN instr("name", ' ') > 0 THEN substr("name", 1, instr("name", ' ') - 1) + ELSE "name" + END, + CASE + WHEN instr("name", ' ') > 0 THEN substr("name", instr("name", ' ') + 1) + ELSE '' + END, + "email", + "password_hash", + "role", + "is_active", + "created_at", + "updated_at" +FROM "users"; + +DROP TABLE "users"; +ALTER TABLE "users_new" RENAME TO "users"; + +CREATE UNIQUE INDEX "users_mobile_key" ON "users"("mobile"); +CREATE UNIQUE INDEX "users_email_key" ON "users"("email"); + +PRAGMA foreign_keys=ON; diff --git a/prisma/migrations/20260727160000_server_accounts/migration.sql b/prisma/migrations/20260727160000_server_accounts/migration.sql new file mode 100644 index 0000000..47af543 --- /dev/null +++ b/prisma/migrations/20260727160000_server_accounts/migration.sql @@ -0,0 +1,47 @@ +-- CreateTable +CREATE TABLE "server_accounts" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenant_id" TEXT NOT NULL, + "is_dedicated" BOOLEAN NOT NULL DEFAULT false, + "server_name" TEXT, + "specs" TEXT, + "price_rials" INTEGER, + "charged_at" DATETIME, + "plan_months" INTEGER, + "expires_at" DATETIME, + "description" TEXT, + "alert_days_before" INTEGER NOT NULL DEFAULT 3, + "last_alert_date" DATETIME, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "server_accounts_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "server_charges" ( + "id" TEXT NOT NULL PRIMARY KEY, + "server_account_id" TEXT NOT NULL, + "charged_at" DATETIME NOT NULL, + "plan_months" INTEGER NOT NULL, + "price_rials" INTEGER NOT NULL, + "expires_at" DATETIME NOT NULL, + "note" TEXT, + "created_by_id" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "server_charges_server_account_id_fkey" FOREIGN KEY ("server_account_id") REFERENCES "server_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE UNIQUE INDEX "server_accounts_tenant_id_key" ON "server_accounts"("tenant_id"); + +-- CreateIndex +CREATE INDEX "server_accounts_expires_at_idx" ON "server_accounts"("expires_at"); + +-- CreateIndex +CREATE INDEX "server_accounts_is_dedicated_idx" ON "server_accounts"("is_dedicated"); + +-- CreateIndex +CREATE INDEX "server_charges_server_account_id_idx" ON "server_charges"("server_account_id"); + +-- CreateIndex +CREATE INDEX "server_charges_created_at_idx" ON "server_charges"("created_at"); diff --git a/prisma/migrations/20260728120000_plugin_subscriptions/migration.sql b/prisma/migrations/20260728120000_plugin_subscriptions/migration.sql new file mode 100644 index 0000000..c97ccf8 --- /dev/null +++ b/prisma/migrations/20260728120000_plugin_subscriptions/migration.sql @@ -0,0 +1,47 @@ +-- CreateTable +CREATE TABLE "plugin_subscriptions" ( + "id" TEXT NOT NULL PRIMARY KEY, + "tenant_id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "specs" TEXT, + "price_rials" INTEGER, + "charged_at" DATETIME, + "plan_months" INTEGER, + "expires_at" DATETIME, + "description" TEXT, + "alert_days_before" INTEGER NOT NULL DEFAULT 3, + "last_alert_date" DATETIME, + "is_active" BOOLEAN NOT NULL DEFAULT true, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" DATETIME NOT NULL, + CONSTRAINT "plugin_subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "tenants" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateTable +CREATE TABLE "plugin_charges" ( + "id" TEXT NOT NULL PRIMARY KEY, + "plugin_id" TEXT NOT NULL, + "charged_at" DATETIME NOT NULL, + "plan_months" INTEGER NOT NULL, + "price_rials" INTEGER NOT NULL, + "expires_at" DATETIME NOT NULL, + "note" TEXT, + "created_by_id" TEXT, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "plugin_charges_plugin_id_fkey" FOREIGN KEY ("plugin_id") REFERENCES "plugin_subscriptions" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "plugin_subscriptions_tenant_id_idx" ON "plugin_subscriptions"("tenant_id"); + +-- CreateIndex +CREATE INDEX "plugin_subscriptions_expires_at_idx" ON "plugin_subscriptions"("expires_at"); + +-- CreateIndex +CREATE INDEX "plugin_subscriptions_is_active_idx" ON "plugin_subscriptions"("is_active"); + +-- CreateIndex +CREATE INDEX "plugin_charges_plugin_id_idx" ON "plugin_charges"("plugin_id"); + +-- CreateIndex +CREATE INDEX "plugin_charges_created_at_idx" ON "plugin_charges"("created_at"); diff --git a/prisma/migrations/20260729150000_sms_panel_parity/migration.sql b/prisma/migrations/20260729150000_sms_panel_parity/migration.sql new file mode 100644 index 0000000..4129b7a --- /dev/null +++ b/prisma/migrations/20260729150000_sms_panel_parity/migration.sql @@ -0,0 +1,36 @@ +-- AlterTable +ALTER TABLE "sms_accounts" ADD COLUMN "charge_card" TEXT; +ALTER TABLE "sms_accounts" ADD COLUMN "charge_iban" TEXT; +ALTER TABLE "sms_accounts" ADD COLUMN "charge_holder" TEXT; +ALTER TABLE "sms_accounts" ADD COLUMN "charge_instruction" TEXT; + +-- AlterTable +ALTER TABLE "sms_ledgers" ADD COLUMN "balance_after_rials" INTEGER; + +-- CreateIndex +CREATE INDEX "sms_ledgers_type_idx" ON "sms_ledgers"("type"); + +-- CreateTable +CREATE TABLE "sms_send_logs" ( + "id" TEXT NOT NULL PRIMARY KEY, + "sms_account_id" TEXT NOT NULL, + "receiver" TEXT NOT NULL, + "body" TEXT NOT NULL, + "parts" INTEGER NOT NULL DEFAULT 1, + "price_per_part_rials" INTEGER NOT NULL, + "total_amount_rials" INTEGER NOT NULL, + "provider" TEXT, + "source" TEXT, + "is_alert" BOOLEAN NOT NULL DEFAULT false, + "created_at" DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT "sms_send_logs_sms_account_id_fkey" FOREIGN KEY ("sms_account_id") REFERENCES "sms_accounts" ("id") ON DELETE CASCADE ON UPDATE CASCADE +); + +-- CreateIndex +CREATE INDEX "sms_send_logs_sms_account_id_idx" ON "sms_send_logs"("sms_account_id"); + +-- CreateIndex +CREATE INDEX "sms_send_logs_created_at_idx" ON "sms_send_logs"("created_at"); + +-- CreateIndex +CREATE INDEX "sms_send_logs_provider_idx" ON "sms_send_logs"("provider"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index de561c6..0975216 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -26,9 +26,12 @@ enum SmsLedgerType { model User { id String @id @default(cuid()) - email String @unique + mobile String @unique + firstName String @map("first_name") + lastName String @map("last_name") + /// Optional legacy/contact email — login uses mobile + email String? @unique passwordHash String @map("password_hash") - name String role UserRole @default(super_admin) isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") @@ -60,12 +63,16 @@ model Tenant { smsSyncedAt DateTime? @map("sms_synced_at") /// Full Khanegi UI JSON (theme, navigation, pages). Hamkar: unused for now. appUiConfig Json? @map("app_ui_config") + /// Client cache-bust / force-reload signal for this sold version (GET /get_version). + appVersion String @default("1") @map("app_version") isActive Boolean @default(true) @map("is_active") notes String? createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") auditLogs AuditLog[] smsAccount SmsAccount? + serverAccount ServerAccount? + pluginSubscriptions PluginSubscription[] @@index([product]) @@index([isActive]) @@ -91,6 +98,11 @@ model SmsAccount { panelUsername String? @map("panel_username") panelPassword String? @map("panel_password") panelFrom String? @map("panel_from") + /// Deposit instructions shown to tenant managers (parity with Hamkar sms-panel) + chargeCard String? @map("charge_card") + chargeIban String? @map("charge_iban") + chargeHolder String? @map("charge_holder") + chargeInstruction String? @map("charge_instruction") extraSettings Json? @map("extra_settings") todayMessages Int @default(0) @map("today_messages") todayParts Int @default(0) @map("today_parts") @@ -101,29 +113,54 @@ model SmsAccount { createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") - tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) - ledgers SmsLedger[] + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + ledgers SmsLedger[] + sendLogs SmsSendLog[] @@map("sms_accounts") } model SmsLedger { - id String @id @default(cuid()) - smsAccountId String @map("sms_account_id") - type SmsLedgerType - amountRials Int @map("amount_rials") - parts Int? - note String? - createdById String? @map("created_by_id") - createdAt DateTime @default(now()) @map("created_at") + id String @id @default(cuid()) + smsAccountId String @map("sms_account_id") + type SmsLedgerType + amountRials Int @map("amount_rials") + balanceAfterRials Int? @map("balance_after_rials") + parts Int? + note String? + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade) @@index([smsAccountId]) @@index([createdAt]) + @@index([type]) @@map("sms_ledgers") } +/// Per-tenant SMS send history (filled when products hook consume; empty until then) +model SmsSendLog { + id String @id @default(cuid()) + smsAccountId String @map("sms_account_id") + receiver String + body String + parts Int @default(1) + pricePerPartRials Int @map("price_per_part_rials") + totalAmountRials Int @map("total_amount_rials") + provider String? + source String? + isAlert Boolean @default(false) @map("is_alert") + createdAt DateTime @default(now()) @map("created_at") + + smsAccount SmsAccount @relation(fields: [smsAccountId], references: [id], onDelete: Cascade) + + @@index([smsAccountId]) + @@index([createdAt]) + @@index([provider]) + @@map("sms_send_logs") +} + model AuditLog { id String @id @default(cuid()) action String @@ -144,3 +181,99 @@ model AuditLog { @@index([createdAt]) @@map("audit_logs") } + +/// Server hosting status for a sold version. +/// isDedicated=true → customer-owned; SuperAdmin only tracks that flag. +/// isDedicated=false → we manage charge/renewal/expiry alerts. +model ServerAccount { + id String @id @default(cuid()) + tenantId String @unique @map("tenant_id") + isDedicated Boolean @default(false) @map("is_dedicated") + serverName String? @map("server_name") + specs String? + /// Price of the current/last plan period (rials) + priceRials Int? @map("price_rials") + /// Start of current paid period + chargedAt DateTime? @map("charged_at") + /// Plan length in months: 1 | 3 | 6 | 12 + planMonths Int? @map("plan_months") + expiresAt DateTime? @map("expires_at") + description String? + /// Days before expiry to start SMS alerts (default 3) + alertDaysBefore Int @default(3) @map("alert_days_before") + /// Last calendar day an expiry alert was sent (avoid multi-send same day) + lastAlertDate DateTime? @map("last_alert_date") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + charges ServerCharge[] + + @@index([expiresAt]) + @@index([isDedicated]) + @@map("server_accounts") +} + +model ServerCharge { + id String @id @default(cuid()) + serverAccountId String @map("server_account_id") + chargedAt DateTime @map("charged_at") + planMonths Int @map("plan_months") + priceRials Int @map("price_rials") + expiresAt DateTime @map("expires_at") + note String? + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + + serverAccount ServerAccount @relation(fields: [serverAccountId], references: [id], onDelete: Cascade) + + @@index([serverAccountId]) + @@index([createdAt]) + @@map("server_charges") +} + +/// Paid plugin/addon subscription for a sold version (0..N per tenant). +model PluginSubscription { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + /// Plugin display name (required; each tenant can have many) + name String + specs String? + priceRials Int? @map("price_rials") + chargedAt DateTime? @map("charged_at") + /// Plan length in months: 1 | 3 | 6 | 12 + planMonths Int? @map("plan_months") + expiresAt DateTime? @map("expires_at") + description String? + alertDaysBefore Int @default(3) @map("alert_days_before") + lastAlertDate DateTime? @map("last_alert_date") + isActive Boolean @default(true) @map("is_active") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + charges PluginCharge[] + + @@index([tenantId]) + @@index([expiresAt]) + @@index([isActive]) + @@map("plugin_subscriptions") +} + +model PluginCharge { + id String @id @default(cuid()) + pluginId String @map("plugin_id") + chargedAt DateTime @map("charged_at") + planMonths Int @map("plan_months") + priceRials Int @map("price_rials") + expiresAt DateTime @map("expires_at") + note String? + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + + plugin PluginSubscription @relation(fields: [pluginId], references: [id], onDelete: Cascade) + + @@index([pluginId]) + @@index([createdAt]) + @@map("plugin_charges") +} diff --git a/prisma/seed.ts b/prisma/seed.ts index a91a990..9fa0ca2 100644 --- a/prisma/seed.ts +++ b/prisma/seed.ts @@ -4,87 +4,47 @@ import * as bcrypt from 'bcrypt'; const prisma = new PrismaClient(); async function main() { - const email = (process.env.SEED_ADMIN_EMAIL || 'admin@abshode.local').toLowerCase(); + const mobile = (process.env.SEED_ADMIN_MOBILE || '09128081392').replace(/\D/g, ''); + const normalized = + mobile.startsWith('98') && mobile.length >= 12 + ? `0${mobile.slice(2)}` + : mobile.startsWith('9') && mobile.length === 10 + ? `0${mobile}` + : mobile; const password = process.env.SEED_ADMIN_PASSWORD || 'Admin@12345'; - const name = process.env.SEED_ADMIN_NAME || 'Super Admin'; + const firstName = process.env.SEED_ADMIN_FIRST_NAME || 'مدیر'; + const lastName = process.env.SEED_ADMIN_LAST_NAME || 'اصلی'; + const email = (process.env.SEED_ADMIN_EMAIL || 'admin@abshode.local').toLowerCase(); const passwordHash = await bcrypt.hash(password, 10); const user = await prisma.user.upsert({ - where: { email }, - update: { passwordHash, name, isActive: true, role: 'super_admin' }, + where: { mobile: normalized }, + update: { + passwordHash, + firstName, + lastName, + email, + isActive: true, + role: 'super_admin', + }, create: { + mobile: normalized, + firstName, + lastName, email, passwordHash, - name, role: 'super_admin', isActive: true, }, }); - // Extracted from Hamkar SettingsSeeder (bakhtargold) — real project defaults found in code - const bakhtarSmsValues = { - name_fa: 'باختر گلد', - name_en: 'bakhtargold', - app_url: 'bakhtargold.com', - admin_url: 'https://bakhtargold.com/admin', - sms_long: 'true', - sms_panel: '', - melipayamak_user: '', - melipayamak_password: '', - melipayamak_from: '', - sms_panel_username: '', - sms_panel_password: '', - sms_panel_from: '', - send_sms_on_customer_order: '', - sms_charge_card: '', - sms_charge_iban: '', - sms_charge_account_holder: '', - sms_charge_instruction: '', - }; - - const bakhtar = await prisma.tenant.upsert({ - where: { slug: 'bakhtargold' }, - update: { - nameFa: bakhtarSmsValues.name_fa, - nameEn: bakhtarSmsValues.name_en, - domain: 'bakhtargold.com', - appUrl: bakhtarSmsValues.app_url, - adminUrl: bakhtarSmsValues.admin_url, - smsSettingsCache: { - values: bakhtarSmsValues, - wallet: null, - source: 'hamkar/database/seeders/SettingsSeeder.php', - syncedAt: new Date().toISOString(), - note: 'استخراج از سیدر کد؛ برای داده زنده DB باید همگام‌سازی MySQL انجام شود', - }, - smsSyncedAt: new Date(), - }, - create: { - product: 'hamkar', - slug: 'bakhtargold', - nameFa: bakhtarSmsValues.name_fa, - nameEn: bakhtarSmsValues.name_en, - domain: 'bakhtargold.com', - primaryColor: '#20c997', - nodeServer: 'https://bakhtargold.com/server', - appUrl: bakhtarSmsValues.app_url, - adminUrl: bakhtarSmsValues.admin_url, - isActive: true, - notes: 'نسخه استخراج‌شده از کد/سیدر همکار', - smsSettingsCache: { - values: bakhtarSmsValues, - wallet: null, - source: 'hamkar/database/seeders/SettingsSeeder.php', - syncedAt: new Date().toISOString(), - }, - smsSyncedAt: new Date(), - }, + console.log('Seeded admin:', { + id: user.id, + mobile: user.mobile, + name: `${user.firstName} ${user.lastName}`, }); - - console.log('Seeded admin:', { id: user.id, email: user.email }); - console.log('Seeded tenant:', { id: bakhtar.id, slug: bakhtar.slug }); - console.log('Login with:', email, '/', password); + console.log('Login with:', normalized, '/', password); } main() diff --git a/src/admins/admins.controller.ts b/src/admins/admins.controller.ts new file mode 100644 index 0000000..50efd55 --- /dev/null +++ b/src/admins/admins.controller.ts @@ -0,0 +1,55 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { AdminsService } from './admins.service'; +import { CreateAdminDto, TestSmsDto, UpdateAdminDto } from './dto/admin.dto'; + +@Controller('admins') +@UseGuards(JwtAuthGuard) +export class AdminsController { + constructor(private readonly adminsService: AdminsService) {} + + @Get() + list() { + return this.adminsService.list(); + } + + @Post() + create( + @Body() dto: CreateAdminDto, + @CurrentUser() user: { id: string }, + ) { + return this.adminsService.create(dto, user.id); + } + + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateAdminDto, + @CurrentUser() user: { id: string }, + ) { + return this.adminsService.update(id, dto, user.id); + } + + @Delete(':id') + remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { + return this.adminsService.remove(id, user.id); + } + + @Post(':id/test-sms') + testSms( + @Param('id') id: string, + @Body() dto: TestSmsDto, + ) { + return this.adminsService.sendTestSms(id, dto.message); + } +} diff --git a/src/admins/admins.module.ts b/src/admins/admins.module.ts new file mode 100644 index 0000000..ab1f58f --- /dev/null +++ b/src/admins/admins.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { AdminsController } from './admins.controller'; +import { AdminsService } from './admins.service'; + +@Module({ + imports: [NotificationsModule], + controllers: [AdminsController], + providers: [AdminsService], + exports: [AdminsService], +}) +export class AdminsModule {} diff --git a/src/admins/admins.service.ts b/src/admins/admins.service.ts new file mode 100644 index 0000000..e147818 --- /dev/null +++ b/src/admins/admins.service.ts @@ -0,0 +1,222 @@ +import { + BadRequestException, + ConflictException, + Injectable, + NotFoundException, +} from '@nestjs/common'; +import { Prisma } from '@prisma/client'; +import * as bcrypt from 'bcrypt'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { AdminNotifyService } from '../notifications/admin-notify.service'; +import { CentralSmsService } from '../notifications/central-sms.service'; +import { displayName, normalizeMobile } from '../common/mobile.util'; +import { CreateAdminDto, UpdateAdminDto } from './dto/admin.dto'; + +@Injectable() +export class AdminsService { + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly notify: AdminNotifyService, + private readonly sms: CentralSmsService, + ) {} + + list() { + return this.prisma.user.findMany({ + orderBy: [{ isActive: 'desc' }, { createdAt: 'asc' }], + select: { + id: true, + mobile: true, + firstName: true, + lastName: true, + email: true, + role: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }).then((rows) => + rows.map((row) => ({ + ...row, + name: displayName(row.firstName, row.lastName), + })), + ); + } + + async create(dto: CreateAdminDto, actorId?: string) { + const mobile = normalizeMobile(dto.mobile); + if (!mobile) { + throw new BadRequestException('شماره موبایل نامعتبر است'); + } + + try { + const passwordHash = await bcrypt.hash(dto.password, 10); + const user = await this.prisma.user.create({ + data: { + mobile, + firstName: dto.firstName.trim(), + lastName: dto.lastName.trim(), + passwordHash, + role: 'super_admin', + isActive: true, + }, + }); + + await this.audit.log({ + action: 'admin.create', + entityType: 'user', + entityId: user.id, + userId: actorId, + after: this.publicUser(user) as unknown as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + `سوپرادمین: مدیر جدید «${displayName(user.firstName, user.lastName)}» با موبایل ${user.mobile} اضافه شد.`, + ); + + return this.publicUser(user); + } catch (error) { + this.handleUnique(error); + throw error; + } + } + + async update(id: string, dto: UpdateAdminDto, actorId?: string) { + const before = await this.prisma.user.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('مدیر یافت نشد'); + + const mobile = + dto.mobile !== undefined ? normalizeMobile(dto.mobile) : undefined; + if (dto.mobile !== undefined && !mobile) { + throw new BadRequestException('شماره موبایل نامعتبر است'); + } + + if (dto.isActive === false) { + await this.ensureNotLastActive(id); + } + + try { + const data: { + firstName?: string; + lastName?: string; + mobile?: string; + isActive?: boolean; + passwordHash?: string; + } = {}; + + if (dto.firstName !== undefined) data.firstName = dto.firstName.trim(); + if (dto.lastName !== undefined) data.lastName = dto.lastName.trim(); + if (mobile) data.mobile = mobile; + if (dto.isActive !== undefined) data.isActive = dto.isActive; + if (dto.password) data.passwordHash = await bcrypt.hash(dto.password, 10); + + const user = await this.prisma.user.update({ + where: { id }, + data, + }); + + await this.audit.log({ + action: 'admin.update', + entityType: 'user', + entityId: user.id, + userId: actorId, + before: this.publicUser(before) as unknown as Prisma.InputJsonValue, + after: this.publicUser(user) as unknown as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + `سوپرادمین: پروفایل مدیر «${displayName(user.firstName, user.lastName)}» به‌روزرسانی شد.`, + ); + + return this.publicUser(user); + } catch (error) { + this.handleUnique(error); + throw error; + } + } + + async remove(id: string, actorId?: string) { + const before = await this.prisma.user.findUnique({ where: { id } }); + if (!before) throw new NotFoundException('مدیر یافت نشد'); + if (actorId && actorId === id) { + throw new BadRequestException('نمی‌توانید خودتان را حذف کنید'); + } + await this.ensureNotLastActive(id); + + await this.prisma.user.delete({ where: { id } }); + + await this.audit.log({ + action: 'admin.delete', + entityType: 'user', + entityId: id, + userId: actorId, + before: this.publicUser(before) as unknown as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + `سوپرادمین: مدیر «${displayName(before.firstName, before.lastName)}» حذف شد.`, + ); + + return { ok: true }; + } + + async sendTestSms(adminId: string, message?: string) { + const user = await this.prisma.user.findUnique({ where: { id: adminId } }); + if (!user) throw new NotFoundException('مدیر یافت نشد'); + + const text = + message?.trim() || + `تست پیامک سوپرادمین آبشده برای ${displayName(user.firstName, user.lastName)}`; + + const result = await this.sms.send(user.mobile, text); + if (!result.success) { + throw new BadRequestException(result.error || 'ارسال پیامک ناموفق بود'); + } + return { ok: true, mobile: user.mobile }; + } + + private async ensureNotLastActive(id: string) { + const activeCount = await this.prisma.user.count({ + where: { isActive: true }, + }); + const target = await this.prisma.user.findUnique({ where: { id } }); + if (target?.isActive && activeCount <= 1) { + throw new BadRequestException('حداقل یک مدیر فعال باید باقی بماند'); + } + } + + private publicUser(user: { + id: string; + mobile: string; + firstName: string; + lastName: string; + email: string | null; + role: string; + isActive: boolean; + createdAt: Date; + updatedAt: Date; + }) { + return { + id: user.id, + mobile: user.mobile, + firstName: user.firstName, + lastName: user.lastName, + name: displayName(user.firstName, user.lastName), + email: user.email, + role: user.role, + isActive: user.isActive, + createdAt: user.createdAt, + updatedAt: user.updatedAt, + }; + } + + private handleUnique(error: unknown) { + if ( + error instanceof Prisma.PrismaClientKnownRequestError && + error.code === 'P2002' + ) { + throw new ConflictException('این شماره موبایل قبلاً ثبت شده است'); + } + } +} diff --git a/src/admins/dto/admin.dto.ts b/src/admins/dto/admin.dto.ts new file mode 100644 index 0000000..7277b9d --- /dev/null +++ b/src/admins/dto/admin.dto.ts @@ -0,0 +1,66 @@ +import { + IsBoolean, + IsOptional, + IsString, + MaxLength, + MinLength, +} from 'class-validator'; + +export class CreateAdminDto { + @IsString() + @MinLength(2) + @MaxLength(64) + firstName!: string; + + @IsString() + @MinLength(2) + @MaxLength(64) + lastName!: string; + + @IsString() + @MinLength(10) + @MaxLength(15) + mobile!: string; + + @IsString() + @MinLength(6) + @MaxLength(72) + password!: string; +} + +export class UpdateAdminDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(64) + firstName?: string; + + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(64) + lastName?: string; + + @IsOptional() + @IsString() + @MinLength(10) + @MaxLength(15) + mobile?: string; + + @IsOptional() + @IsString() + @MinLength(6) + @MaxLength(72) + password?: string; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class TestSmsDto { + @IsOptional() + @IsString() + @MaxLength(500) + message?: string; +} diff --git a/src/app.module.ts b/src/app.module.ts index f87dcd8..be1ea3d 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -4,21 +4,31 @@ import { PrismaModule } from './prisma/prisma.module'; import { AuthModule } from './auth/auth.module'; import { TenantsModule } from './tenants/tenants.module'; import { GetUserModule } from './get-user/get-user.module'; +import { GetVersionModule } from './get-version/get-version.module'; import { SettingsProxyModule } from './settings-proxy/settings-proxy.module'; import { AuditModule } from './audit/audit.module'; import { SmsModule } from './sms/sms.module'; +import { ServersModule } from './servers/servers.module'; +import { PluginsModule } from './plugins/plugins.module'; +import { NotificationsModule } from './notifications/notifications.module'; +import { AdminsModule } from './admins/admins.module'; import { HealthController } from './health.controller'; @Module({ imports: [ ConfigModule.forRoot({ isGlobal: true }), PrismaModule, + NotificationsModule, AuthModule, TenantsModule, GetUserModule, + GetVersionModule, SettingsProxyModule, AuditModule, SmsModule, + ServersModule, + PluginsModule, + AdminsModule, ], controllers: [HealthController], }) diff --git a/src/audit/audit.service.ts b/src/audit/audit.service.ts index c7b516b..e524690 100644 --- a/src/audit/audit.service.ts +++ b/src/audit/audit.service.ts @@ -33,27 +33,52 @@ export class AuditService { } findAll(limit = 50) { - return this.prisma.auditLog.findMany({ - take: Math.min(limit, 200), - orderBy: { createdAt: 'desc' }, - include: { - user: { select: { id: true, name: true, email: true } }, - tenant: { select: { id: true, slug: true, nameFa: true } }, - }, - }); + return this.prisma.auditLog + .findMany({ + take: Math.min(limit, 200), + orderBy: { createdAt: 'desc' }, + include: { + user: { + select: { id: true, firstName: true, lastName: true, mobile: true }, + }, + tenant: { select: { id: true, slug: true, nameFa: true } }, + }, + }) + .then((rows) => + rows.map((row) => ({ + ...row, + user: row.user + ? { + ...row.user, + name: `${row.user.firstName} ${row.user.lastName}`.trim(), + } + : null, + })), + ); } async findOne(id: string) { const row = await this.prisma.auditLog.findUnique({ where: { id }, include: { - user: { select: { id: true, name: true, email: true } }, + user: { + select: { id: true, firstName: true, lastName: true, mobile: true }, + }, tenant: { select: { id: true, slug: true, nameFa: true } }, }, }); if (!row) { throw new NotFoundException('لاگ یافت نشد'); } - return row; + return { + ...row, + user: row.user + ? { + ...row.user, + name: `${row.user.firstName} ${row.user.lastName}`.trim(), + } + : null, + }; } } + diff --git a/src/auth/auth.service.ts b/src/auth/auth.service.ts index 65c1709..2dff871 100644 --- a/src/auth/auth.service.ts +++ b/src/auth/auth.service.ts @@ -1,8 +1,8 @@ import { Injectable, UnauthorizedException } from '@nestjs/common'; -import { ConfigService } from '@nestjs/config'; import { JwtService } from '@nestjs/jwt'; import * as bcrypt from 'bcrypt'; import { PrismaService } from '../prisma/prisma.service'; +import { displayName, normalizeMobile } from '../common/mobile.util'; import { LoginDto } from './dto/login.dto'; @Injectable() @@ -10,26 +10,31 @@ export class AuthService { constructor( private readonly prisma: PrismaService, private readonly jwt: JwtService, - private readonly config: ConfigService, ) {} async login(dto: LoginDto) { + const mobile = normalizeMobile(dto.mobile); + if (!mobile) { + throw new UnauthorizedException('موبایل یا رمز عبور نادرست است'); + } + const user = await this.prisma.user.findUnique({ - where: { email: dto.email.toLowerCase() }, + where: { mobile }, }); if (!user || !user.isActive) { - throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است'); + throw new UnauthorizedException('موبایل یا رمز عبور نادرست است'); } const ok = await bcrypt.compare(dto.password, user.passwordHash); if (!ok) { - throw new UnauthorizedException('ایمیل یا رمز عبور نادرست است'); + throw new UnauthorizedException('موبایل یا رمز عبور نادرست است'); } + const name = displayName(user.firstName, user.lastName); const payload = { sub: user.id, - email: user.email, + mobile: user.mobile, role: user.role, }; @@ -37,8 +42,10 @@ export class AuthService { accessToken: await this.jwt.signAsync(payload), user: { id: user.id, - email: user.email, - name: user.name, + mobile: user.mobile, + firstName: user.firstName, + lastName: user.lastName, + name, role: user.role, }, }; @@ -49,8 +56,10 @@ export class AuthService { where: { id: userId }, select: { id: true, + mobile: true, + firstName: true, + lastName: true, email: true, - name: true, role: true, isActive: true, createdAt: true, @@ -61,6 +70,9 @@ export class AuthService { throw new UnauthorizedException(); } - return user; + return { + ...user, + name: displayName(user.firstName, user.lastName), + }; } } diff --git a/src/auth/dto/login.dto.ts b/src/auth/dto/login.dto.ts index 52a1d0d..567e463 100644 --- a/src/auth/dto/login.dto.ts +++ b/src/auth/dto/login.dto.ts @@ -2,8 +2,8 @@ import { IsString, MinLength } from 'class-validator'; export class LoginDto { @IsString() - @MinLength(3) - email!: string; + @MinLength(10) + mobile!: string; @IsString() @MinLength(6) diff --git a/src/auth/jwt.strategy.ts b/src/auth/jwt.strategy.ts index 5446edb..2addae9 100644 --- a/src/auth/jwt.strategy.ts +++ b/src/auth/jwt.strategy.ts @@ -3,10 +3,11 @@ import { ConfigService } from '@nestjs/config'; import { PassportStrategy } from '@nestjs/passport'; import { ExtractJwt, Strategy } from 'passport-jwt'; import { PrismaService } from '../prisma/prisma.service'; +import { displayName } from '../common/mobile.util'; export type JwtPayload = { sub: string; - email: string; + mobile: string; role: string; }; @@ -28,8 +29,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { where: { id: payload.sub }, select: { id: true, - email: true, - name: true, + mobile: true, + firstName: true, + lastName: true, role: true, isActive: true, }, @@ -39,6 +41,9 @@ export class JwtStrategy extends PassportStrategy(Strategy) { return null; } - return user; + return { + ...user, + name: displayName(user.firstName, user.lastName), + }; } } diff --git a/src/common/iran-time.util.ts b/src/common/iran-time.util.ts new file mode 100644 index 0000000..ab79019 --- /dev/null +++ b/src/common/iran-time.util.ts @@ -0,0 +1,40 @@ +/** Shared Iran timezone helpers for SuperAdmin API. */ + +export const IRAN_TZ = 'Asia/Tehran'; + +/** Gregorian YYYY-MM-DD for the calendar day in Asia/Tehran. */ +export function iranYmd(date: Date = new Date()): string { + return new Intl.DateTimeFormat('en-CA', { + timeZone: IRAN_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +} + +export function formatIranDate(date: Date): string { + return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + timeZone: IRAN_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + }).format(date); +} + +export function formatIranDateTime(date: Date): string { + return new Intl.DateTimeFormat('fa-IR-u-ca-persian', { + timeZone: IRAN_TZ, + year: 'numeric', + month: '2-digit', + day: '2-digit', + hour: '2-digit', + minute: '2-digit', + }).format(date); +} + +/** Whole days from Iran "today" to Iran calendar day of `expiresAt`. */ +export function iranDaysUntil(expiresAt: Date, now: Date = new Date()): number { + const start = Date.parse(`${iranYmd(now)}T12:00:00+03:30`); + const end = Date.parse(`${iranYmd(expiresAt)}T12:00:00+03:30`); + return Math.round((end - start) / 86_400_000); +} diff --git a/src/common/mobile.util.ts b/src/common/mobile.util.ts new file mode 100644 index 0000000..aba6a09 --- /dev/null +++ b/src/common/mobile.util.ts @@ -0,0 +1,19 @@ +/** Normalize Iranian mobile to 09xxxxxxxxx, or null if invalid. */ +export function normalizeMobile(input?: string | null): string | null { + if (!input) return null; + let digits = String(input).replace(/\D/g, ''); + if (digits.startsWith('98') && digits.length >= 12) { + digits = `0${digits.slice(2)}`; + } + if (digits.startsWith('9') && digits.length === 10) { + digits = `0${digits}`; + } + if (!/^09\d{9}$/.test(digits)) { + return null; + } + return digits; +} + +export function displayName(firstName: string, lastName: string) { + return `${firstName} ${lastName}`.replace(/\s+/g, ' ').trim(); +} diff --git a/src/get-user/get-user.service.ts b/src/get-user/get-user.service.ts index 6b59181..ca97e56 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -1,6 +1,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { PrismaService } from '../prisma/prisma.service'; +type UiSetting = { key: string; value: string }; + @Injectable() export class GetUserService { constructor(private readonly prisma: PrismaService) {} @@ -21,7 +23,41 @@ export class GetUserService { } const ui = (tenant.appUiConfig as Record | null) || null; - const themeColors = ui?.app?.theme?.colors; + const isHamkarUi = tenant.product === 'hamkar' && ui?.kind === 'hamkar'; + const hamkarBrand = isHamkarUi ? ui?.brand : null; + const khanegiColors = ui?.app?.theme?.colors; + const uiSettings = + isHamkarUi && ui?.settingsManaged === true + ? this.buildHamkarUiSettings(ui) + : []; + + // Preserve named CSS modes from live registry (e.g. limangold / Montpar). + // Fallback to notes marker if JSON field missing on older rows. + const noteMode = tenant.notes?.match(/registryMode:([^\s|]+)/)?.[1] || null + const registryMode = + (typeof ui?.registryMode === 'string' && ui.registryMode.trim()) || + noteMode || + null + const themeMode = + registryMode && registryMode !== 'dynamicMode' + ? registryMode + : 'dynamicMode' + + const color = + hamkarBrand?.primaryColor || + khanegiColors?.primary || + tenant.primaryColor || + null + const secondary = + hamkarBrand?.secondaryColor || + khanegiColors?.secondary || + tenant.secondaryColor || + null + const background = + hamkarBrand?.backgroundColor || + khanegiColors?.background || + tenant.backgroundColor || + null const branding = { id: tenant.id, @@ -29,17 +65,19 @@ export class GetUserService { name_fa: tenant.nameFa, name_en: tenant.nameEn, avatar: tenant.avatarUrl, - color: themeColors?.primary || tenant.primaryColor, - secondary_color: themeColors?.secondary || tenant.secondaryColor, - background_color: themeColors?.background || tenant.backgroundColor, + color, + secondary_color: secondary, + background_color: background, node_server: tenant.nodeServer, node_ws: tenant.nodeWs, app_url: tenant.appUrl, admin_url: tenant.adminUrl, product: tenant.product, - theme: 'dynamicMode', - mode: 'dynamicMode', + theme: themeMode, + mode: themeMode, app_ui_config: tenant.product === 'khanegi' ? ui : undefined, + /** Laravel-compatible overlay; Hamkar merges only when present */ + ui_settings: uiSettings.length ? uiSettings : undefined, }; return { @@ -48,6 +86,19 @@ export class GetUserService { }; } + private buildHamkarUiSettings(ui: Record): UiSetting[] { + const settings = ui?.settings; + if (!settings || typeof settings !== 'object') return []; + + const out: UiSetting[] = []; + for (const key of ['pricing_box_theme', 'price_font_size', 'orderUI'] as const) { + const value = settings[key]; + if (value == null || value === '') continue; + out.push({ key, value: String(value) }); + } + return out; + } + private matchesHost( tenant: { domain: string; diff --git a/src/get-version/get-version.controller.ts b/src/get-version/get-version.controller.ts new file mode 100644 index 0000000..c2d65f0 --- /dev/null +++ b/src/get-version/get-version.controller.ts @@ -0,0 +1,19 @@ +import { Controller, Get, Headers, Query } from '@nestjs/common'; +import { GetVersionService } from './get-version.service'; + +@Controller() +export class GetVersionController { + constructor(private readonly getVersionService: GetVersionService) {} + + @Get('get_version') + getVersion( + @Headers('host') host?: string, + @Headers('x-forwarded-host') forwardedHost?: string, + @Query('domain') domain?: string, + ) { + return this.getVersionService.resolveByHost( + domain || forwardedHost || host, + domain, + ); + } +} diff --git a/src/get-version/get-version.module.ts b/src/get-version/get-version.module.ts new file mode 100644 index 0000000..c9c7f9d --- /dev/null +++ b/src/get-version/get-version.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { GetVersionController } from './get-version.controller'; +import { GetVersionService } from './get-version.service'; + +@Module({ + controllers: [GetVersionController], + providers: [GetVersionService], +}) +export class GetVersionModule {} diff --git a/src/get-version/get-version.service.ts b/src/get-version/get-version.service.ts new file mode 100644 index 0000000..5ffedf3 --- /dev/null +++ b/src/get-version/get-version.service.ts @@ -0,0 +1,71 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; + +@Injectable() +export class GetVersionService { + constructor(private readonly prisma: PrismaService) {} + + async resolveByHost(hostHeader?: string, domainQuery?: string) { + const host = this.normalizeHost(domainQuery || hostHeader || ''); + if (!host) { + throw new NotFoundException('domain required'); + } + + const tenants = await this.prisma.tenant.findMany({ + where: { isActive: true }, + select: { + appVersion: true, + domain: true, + domains: true, + }, + }); + + const tenant = tenants.find((item) => this.matchesHost(item, host)); + if (!tenant) { + throw new NotFoundException('tenant not found for domain'); + } + + return { version: tenant.appVersion || '1' }; + } + + private matchesHost( + tenant: { + domain: string; + domains: unknown; + }, + host: string, + ) { + const candidates = new Set(); + for (const value of [tenant.domain, ...this.readDomains(tenant.domains)]) { + const normalized = this.normalizeHost(value); + if (!normalized) continue; + candidates.add(normalized); + candidates.add(normalized.replace(/^www\./, '')); + candidates.add(`www.${normalized.replace(/^www\./, '')}`); + } + return candidates.has(host) || candidates.has(host.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 normalizeHost(value: string) { + return value + .trim() + .toLowerCase() + .replace(/^https?:\/\//, '') + .split('/')[0] + .split(':')[0]; + } +} diff --git a/src/main.ts b/src/main.ts index 7b72357..40710a3 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,18 +1,61 @@ import { NestFactory } from '@nestjs/core'; import { RequestMethod, ValidationPipe } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; +import { NestExpressApplication } from '@nestjs/platform-express'; import { AppModule } from './app.module'; +import { ensureUploadsDirs, UPLOADS_ROOT } from './uploads/uploads.util'; async function bootstrap() { - const app = await NestFactory.create(AppModule); + const app = await NestFactory.create(AppModule); const config = app.get(ConfigService); + ensureUploadsDirs(); + app.useStaticAssets(UPLOADS_ROOT, { prefix: '/uploads/' }); + app.setGlobalPrefix('api', { - exclude: [{ path: 'get_user', method: RequestMethod.GET }], + exclude: [ + { path: 'get_user', method: RequestMethod.GET }, + { path: 'get_version', method: RequestMethod.GET }, + { path: 'uploads/(.*)', method: RequestMethod.GET }, + ], }); + const corsOriginRaw = config.get( + 'CORS_ORIGIN', + 'http://localhost:5173,http://localhost:3100', + ); + const corsOrigins = corsOriginRaw + .split(',') + .map((item) => item.trim()) + .filter(Boolean); + + const isDev = (config.get('NODE_ENV') ?? process.env.NODE_ENV) !== 'production'; + app.enableCors({ - origin: config.get('CORS_ORIGIN', 'http://localhost:5173'), + origin: ( + origin: string | undefined, + callback: (err: Error | null, allow?: boolean) => void, + ) => { + if (!origin) { + callback(null, true); + return; + } + if (corsOrigins.includes(origin)) { + callback(null, true); + return; + } + // Vite روی LAN (مثلاً 172.x / 192.168.x) در توسعه + if ( + isDev && + /^http:\/\/(localhost|127\.0\.0\.1|192\.168\.\d+\.\d+|172\.\d+\.\d+\.\d+|10\.\d+\.\d+\.\d+):\d+$/.test( + origin, + ) + ) { + callback(null, true); + return; + } + callback(new Error(`CORS blocked for origin: ${origin}`), false); + }, credentials: true, }); diff --git a/src/notifications/admin-notify.service.ts b/src/notifications/admin-notify.service.ts new file mode 100644 index 0000000..d685bc2 --- /dev/null +++ b/src/notifications/admin-notify.service.ts @@ -0,0 +1,48 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { CentralSmsService } from './central-sms.service'; +import { RuntimeSettingsService } from './runtime-settings.service'; + +@Injectable() +export class AdminNotifyService { + private readonly logger = new Logger(AdminNotifyService.name); + + constructor( + private readonly prisma: PrismaService, + private readonly sms: CentralSmsService, + private readonly runtime: RuntimeSettingsService, + ) {} + + /** Fire-and-forget SMS to all active managers with a mobile. */ + notifyAdmins(message: string): void { + void this.notifyAdminsAsync(message); + } + + async notifyAdminsAsync(message: string) { + const text = message.trim(); + if (!text) return { sent: 0, failed: 0, skipped: false }; + + if (!this.runtime.getAdminSmsEnabled()) { + this.logger.log(`Admin notify skipped (SMS toggle off): ${text.slice(0, 80)}`); + return { sent: 0, failed: 0, skipped: true }; + } + + const admins = await this.prisma.user.findMany({ + where: { isActive: true }, + select: { id: true, mobile: true, firstName: true }, + }); + + let sent = 0; + let failed = 0; + + for (const admin of admins) { + if (!admin.mobile) continue; + const result = await this.sms.send(admin.mobile, text); + if (result.success) sent += 1; + else failed += 1; + } + + this.logger.log(`Admin notify: sent=${sent} failed=${failed}`); + return { sent, failed, skipped: false }; + } +} diff --git a/src/notifications/central-sms.service.ts b/src/notifications/central-sms.service.ts new file mode 100644 index 0000000..d2eb7d3 --- /dev/null +++ b/src/notifications/central-sms.service.ts @@ -0,0 +1,83 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import axios from 'axios'; +import { normalizeMobile } from '../common/mobile.util'; + +export type CentralSmsResult = { + success: boolean; + raw?: unknown; + error?: string; +}; + +@Injectable() +export class CentralSmsService { + private readonly logger = new Logger(CentralSmsService.name); + + constructor(private readonly config: ConfigService) {} + + async send(phone: string, text: string): Promise { + const to = normalizeMobile(phone); + const message = text.trim(); + + if (!to) { + return { success: false, error: 'شماره موبایل نامعتبر است' }; + } + if (!message) { + return { success: false, error: 'متن پیامک خالی است' }; + } + + const url = + this.config.get('CENTRAL_SMS_URL') || + 'http://185.231.112.101/api/createsms'; + const secret = this.config.get('CENTRAL_SMS_SECRET')?.trim(); + if (!secret) { + this.logger.error('CENTRAL_SMS_SECRET is not configured'); + return { success: false, error: 'سرویس پیامک پیکربندی نشده است' }; + } + const host = + this.config.get('CENTRAL_SMS_HOST') || 'superadmin'; + const platform = + this.config.get('CENTRAL_SMS_PLATFORM') || 'superadmin'; + + try { + const response = await axios.post( + url, + { + phone: to, + host, + text: message, + platform, + }, + { + timeout: 15000, + headers: { + 'Content-Type': 'application/json', + secret_key: secret, + }, + // Prefer IPv4 like Hamkar curl CURLOPT_IPRESOLVE_V4 + family: 4, + validateStatus: () => true, + }, + ); + + const body = response.data; + let success = response.status >= 200 && response.status < 300; + + if (body && typeof body === 'object') { + if (body.success === false || body.ok === false) success = false; + const status = String(body.status ?? '').toLowerCase(); + if (['error', 'failed', 'fail'].includes(status)) success = false; + } + + if (!success) { + this.logger.warn(`Central SMS failed to ${to} (HTTP ${response.status})`); + } + + return { success, raw: body }; + } catch (error: any) { + const msg = error?.message || 'SMS send error'; + this.logger.error(`Central SMS exception to ${to}: ${msg}`); + return { success: false, error: msg }; + } + } +} diff --git a/src/notifications/dto/update-admin-sms.dto.ts b/src/notifications/dto/update-admin-sms.dto.ts new file mode 100644 index 0000000..ab01d48 --- /dev/null +++ b/src/notifications/dto/update-admin-sms.dto.ts @@ -0,0 +1,6 @@ +import { IsBoolean } from 'class-validator'; + +export class UpdateAdminSmsDto { + @IsBoolean() + enabled!: boolean; +} diff --git a/src/notifications/notifications.module.ts b/src/notifications/notifications.module.ts new file mode 100644 index 0000000..6f542b2 --- /dev/null +++ b/src/notifications/notifications.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { AdminNotifyService } from './admin-notify.service'; +import { CentralSmsService } from './central-sms.service'; +import { RuntimeSettingsController } from './runtime-settings.controller'; +import { RuntimeSettingsService } from './runtime-settings.service'; + +@Module({ + controllers: [RuntimeSettingsController], + providers: [CentralSmsService, AdminNotifyService, RuntimeSettingsService], + exports: [CentralSmsService, AdminNotifyService, RuntimeSettingsService], +}) +export class NotificationsModule {} diff --git a/src/notifications/runtime-settings.controller.ts b/src/notifications/runtime-settings.controller.ts new file mode 100644 index 0000000..440c4d9 --- /dev/null +++ b/src/notifications/runtime-settings.controller.ts @@ -0,0 +1,21 @@ +import { Body, Controller, Get, Patch, UseGuards } from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { UpdateAdminSmsDto } from './dto/update-admin-sms.dto'; +import { RuntimeSettingsService } from './runtime-settings.service'; + +@Controller('settings') +@UseGuards(JwtAuthGuard) +export class RuntimeSettingsController { + constructor(private readonly settings: RuntimeSettingsService) {} + + @Get('admin-sms') + getAdminSms() { + return this.settings.getPublic(); + } + + @Patch('admin-sms') + setAdminSms(@Body() dto: UpdateAdminSmsDto) { + this.settings.setAdminSmsEnabled(dto.enabled); + return this.settings.getPublic(); + } +} diff --git a/src/notifications/runtime-settings.service.ts b/src/notifications/runtime-settings.service.ts new file mode 100644 index 0000000..483232a --- /dev/null +++ b/src/notifications/runtime-settings.service.ts @@ -0,0 +1,62 @@ +import { Injectable, Logger } from '@nestjs/common'; +import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs'; +import { join } from 'path'; + +type RuntimeSettings = { + /** When false, SuperAdmin stops SMS alerts to managers */ + adminSmsEnabled: boolean; +}; + +const DEFAULTS: RuntimeSettings = { + adminSmsEnabled: true, +}; + +@Injectable() +export class RuntimeSettingsService { + private readonly logger = new Logger(RuntimeSettingsService.name); + private readonly filePath = join(process.cwd(), 'data', 'runtime-settings.json'); + private cache: RuntimeSettings | null = null; + + getAdminSmsEnabled(): boolean { + return this.read().adminSmsEnabled !== false; + } + + setAdminSmsEnabled(enabled: boolean) { + const next = { ...this.read(), adminSmsEnabled: Boolean(enabled) }; + this.write(next); + this.logger.log(`Admin SMS alerts ${next.adminSmsEnabled ? 'enabled' : 'disabled'}`); + return next; + } + + getPublic() { + return { + adminSmsEnabled: this.getAdminSmsEnabled(), + }; + } + + private read(): RuntimeSettings { + if (this.cache) return this.cache; + try { + if (!existsSync(this.filePath)) { + this.cache = { ...DEFAULTS }; + return this.cache; + } + const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial; + this.cache = { + adminSmsEnabled: raw.adminSmsEnabled !== false, + }; + return this.cache; + } catch (error: any) { + this.logger.warn(`Failed to read runtime settings: ${error?.message || error}`); + this.cache = { ...DEFAULTS }; + return this.cache; + } + } + + private write(settings: RuntimeSettings) { + const dir = join(process.cwd(), 'data'); + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); + writeFileSync(this.filePath, JSON.stringify(settings, null, 2), 'utf8'); + this.cache = settings; + } +} diff --git a/src/plugins/dto/plugin.dto.ts b/src/plugins/dto/plugin.dto.ts new file mode 100644 index 0000000..a3b6bcc --- /dev/null +++ b/src/plugins/dto/plugin.dto.ts @@ -0,0 +1,121 @@ +import { + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + Max, + MaxLength, + Min, + MinLength, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +export const PLUGIN_PLAN_MONTHS = [1, 3, 6, 12] as const; + +export class CreatePluginDto { + @IsString() + @MinLength(2) + @MaxLength(120) + name!: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + specs?: string; + + @IsOptional() + @IsString() + @MaxLength(4000) + description?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + priceRials?: number; + + @IsOptional() + @IsISO8601() + chargedAt?: string; + + @IsOptional() + @Type(() => Number) + @IsInt() + @IsIn(PLUGIN_PLAN_MONTHS as unknown as number[]) + planMonths?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(30) + alertDaysBefore?: number; +} + +export class UpdatePluginDto { + @IsOptional() + @IsString() + @MinLength(2) + @MaxLength(120) + name?: string; + + @IsOptional() + @IsString() + @MaxLength(2000) + specs?: string | null; + + @IsOptional() + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + priceRials?: number | null; + + @IsOptional() + @IsISO8601() + chargedAt?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @IsIn(PLUGIN_PLAN_MONTHS as unknown as number[]) + planMonths?: number | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(30) + alertDaysBefore?: number; + + @IsOptional() + @IsBoolean() + isActive?: boolean; +} + +export class RenewPluginDto { + @IsOptional() + @IsISO8601() + chargedAt?: string; + + @Type(() => Number) + @IsInt() + @IsIn(PLUGIN_PLAN_MONTHS as unknown as number[]) + planMonths!: number; + + @Type(() => Number) + @IsInt() + @Min(0) + priceRials!: number; + + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} diff --git a/src/plugins/plugins.controller.ts b/src/plugins/plugins.controller.ts new file mode 100644 index 0000000..71d0c87 --- /dev/null +++ b/src/plugins/plugins.controller.ts @@ -0,0 +1,77 @@ +import { + Body, + Controller, + Delete, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { + CreatePluginDto, + RenewPluginDto, + UpdatePluginDto, +} from './dto/plugin.dto'; +import { PluginsService } from './plugins.service'; + +@Controller('plugins') +@UseGuards(JwtAuthGuard) +export class PluginsController { + constructor(private readonly pluginsService: PluginsService) {} + + @Get('tenants') + listTenants() { + return this.pluginsService.listTenants(); + } + + @Get('expiring') + expiring(@Query('limit') limit?: string) { + return this.pluginsService.getExpiringSoon(Number(limit) || 5); + } + + @Get('tenants/:tenantId') + getTenantPlugins(@Param('tenantId') tenantId: string) { + return this.pluginsService.getTenantPlugins(tenantId); + } + + @Post('tenants/:tenantId') + create( + @Param('tenantId') tenantId: string, + @Body() dto: CreatePluginDto, + @CurrentUser() user: { id: string }, + ) { + return this.pluginsService.create(tenantId, dto, user.id); + } + + @Get('items/:id') + getOne(@Param('id') id: string) { + return this.pluginsService.getPlugin(id); + } + + @Patch('items/:id') + update( + @Param('id') id: string, + @Body() dto: UpdatePluginDto, + @CurrentUser() user: { id: string }, + ) { + return this.pluginsService.update(id, dto, user.id); + } + + @Post('items/:id/renew') + renew( + @Param('id') id: string, + @Body() dto: RenewPluginDto, + @CurrentUser() user: { id: string }, + ) { + return this.pluginsService.renew(id, dto, user.id); + } + + @Delete('items/:id') + remove(@Param('id') id: string, @CurrentUser() user: { id: string }) { + return this.pluginsService.remove(id, user.id); + } +} diff --git a/src/plugins/plugins.module.ts b/src/plugins/plugins.module.ts new file mode 100644 index 0000000..2460e59 --- /dev/null +++ b/src/plugins/plugins.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { PluginsController } from './plugins.controller'; +import { PluginsService } from './plugins.service'; + +@Module({ + imports: [NotificationsModule], + controllers: [PluginsController], + providers: [PluginsService], + exports: [PluginsService], +}) +export class PluginsModule {} diff --git a/src/plugins/plugins.service.ts b/src/plugins/plugins.service.ts new file mode 100644 index 0000000..7f3db12 --- /dev/null +++ b/src/plugins/plugins.service.ts @@ -0,0 +1,557 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { PluginSubscription, Prisma } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { AdminNotifyService } from '../notifications/admin-notify.service'; +import { + formatIranDate, + iranDaysUntil, + iranYmd, +} from '../common/iran-time.util'; +import { + CreatePluginDto, + PLUGIN_PLAN_MONTHS, + RenewPluginDto, + UpdatePluginDto, +} from './dto/plugin.dto'; + +export type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'; + +@Injectable() +export class PluginsService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(PluginsService.name); + private alertTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly notify: AdminNotifyService, + ) {} + + onModuleInit() { + setTimeout(() => void this.runExpiryAlerts(), 20_000); + this.alertTimer = setInterval( + () => void this.runExpiryAlerts(), + 6 * 60 * 60 * 1000, + ); + } + + onModuleDestroy() { + if (this.alertTimer) clearInterval(this.alertTimer); + } + + async listTenants() { + void this.runExpiryAlerts(); + + const rows = await this.prisma.tenant.findMany({ + where: { isActive: true }, + orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], + include: { + pluginSubscriptions: { + where: { isActive: true }, + orderBy: { name: 'asc' }, + }, + }, + }); + + return rows.map((tenant) => { + const plugins = tenant.pluginSubscriptions.map((p) => this.toSummary(p)); + const worst = this.worstLevel(plugins.map((p) => p.expiryLevel)); + return { + id: tenant.id, + product: tenant.product, + slug: tenant.slug, + nameFa: tenant.nameFa, + nameEn: tenant.nameEn, + domain: tenant.domain, + avatarUrl: tenant.avatarUrl, + pluginCount: plugins.length, + plugins, + summary: { + pluginCount: plugins.length, + expiryLevel: worst, + expiryLabel: + plugins.length === 0 + ? 'بدون افزونه' + : worst === 'critical' + ? 'افزونه نزدیک انقضا / منقضی' + : worst === 'warning' + ? 'افزونه در بازه هشدار' + : worst === 'unknown' + ? 'افزونه بدون تاریخ' + : `${plugins.length} افزونه فعال`, + }, + }; + }); + } + + async getExpiringSoon(limit = 5) { + const plugins = await this.prisma.pluginSubscription.findMany({ + where: { + isActive: true, + expiresAt: { not: null }, + tenant: { isActive: true }, + }, + include: { + tenant: { + select: { + id: true, + product: true, + nameFa: true, + domain: true, + slug: true, + }, + }, + }, + }); + + return plugins + .map((p) => ({ + ...this.toSummary(p), + id: p.id, + tenantId: p.tenantId, + tenant: p.tenant, + })) + .filter((p) => { + const level = p.expiryLevel; + return level === 'critical' || level === 'warning' || level === 'unknown'; + }) + .sort((a, b) => { + const ae = a.expiresAt ? Date.parse(a.expiresAt) : Infinity; + const be = b.expiresAt ? Date.parse(b.expiresAt) : Infinity; + return ae - be; + }) + .slice(0, limit); + } + + async getTenantPlugins(tenantId: string) { + const tenant = await this.requireTenant(tenantId); + const plugins = await this.prisma.pluginSubscription.findMany({ + where: { tenantId }, + orderBy: [{ isActive: 'desc' }, { name: 'asc' }], + include: { + charges: { orderBy: { createdAt: 'desc' }, take: 10 }, + }, + }); + + return { + tenant: { + id: tenant.id, + product: tenant.product, + slug: tenant.slug, + nameFa: tenant.nameFa, + nameEn: tenant.nameEn, + domain: tenant.domain, + avatarUrl: tenant.avatarUrl, + }, + plugins: plugins.map((p) => ({ + ...this.publicPlugin(p), + summary: this.toSummary(p), + charges: p.charges, + })), + planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({ + months: m, + label: this.planLabel(m), + })), + }; + } + + async getPlugin(pluginId: string) { + const plugin = await this.prisma.pluginSubscription.findUnique({ + where: { id: pluginId }, + include: { + tenant: true, + charges: { orderBy: { createdAt: 'desc' }, take: 20 }, + }, + }); + if (!plugin) throw new NotFoundException('افزونه یافت نشد'); + + return { + tenant: { + id: plugin.tenant.id, + product: plugin.tenant.product, + slug: plugin.tenant.slug, + nameFa: plugin.tenant.nameFa, + domain: plugin.tenant.domain, + }, + plugin: this.publicPlugin(plugin), + summary: this.toSummary(plugin), + charges: plugin.charges, + planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({ + months: m, + label: this.planLabel(m), + })), + }; + } + + async create(tenantId: string, dto: CreatePluginDto, userId?: string) { + await this.requireTenant(tenantId); + const name = dto.name.trim(); + if (!name) throw new BadRequestException('نام افزونه لازم است'); + + let chargedAt: Date | null = dto.chargedAt ? new Date(dto.chargedAt) : null; + let planMonths = dto.planMonths ?? null; + let expiresAt: Date | null = null; + + if (chargedAt && planMonths) { + if (!PLUGIN_PLAN_MONTHS.includes(planMonths as any)) { + throw new BadRequestException('مدت اشتراک نامعتبر است'); + } + expiresAt = this.addMonths(chargedAt, planMonths); + } + + const plugin = await this.prisma.pluginSubscription.create({ + data: { + tenantId, + name, + specs: dto.specs?.trim() || null, + description: dto.description?.trim() || null, + priceRials: dto.priceRials ?? null, + chargedAt, + planMonths, + expiresAt, + alertDaysBefore: dto.alertDaysBefore ?? 3, + }, + }); + + if (chargedAt && planMonths && expiresAt && dto.priceRials != null) { + await this.prisma.pluginCharge.create({ + data: { + pluginId: plugin.id, + chargedAt, + planMonths, + priceRials: dto.priceRials, + expiresAt, + note: 'ثبت اولیه', + createdById: userId, + }, + }); + } + + await this.audit.log({ + action: 'plugin.create', + entityType: 'plugin_subscription', + entityId: plugin.id, + tenantId, + userId, + after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue, + }); + + const tenant = await this.requireTenant(tenantId); + this.notify.notifyAdmins( + `سوپرادمین: افزونه «${plugin.name}» برای نسخه «${tenant.nameFa}» اضافه شد.`, + ); + + return this.getPlugin(plugin.id); + } + + async update(pluginId: string, dto: UpdatePluginDto, userId?: string) { + const before = await this.prisma.pluginSubscription.findUnique({ + where: { id: pluginId }, + }); + if (!before) throw new NotFoundException('افزونه یافت نشد'); + + if (dto.planMonths != null && !PLUGIN_PLAN_MONTHS.includes(dto.planMonths as any)) { + throw new BadRequestException('مدت اشتراک نامعتبر است'); + } + + let chargedAt = before.chargedAt; + let planMonths = before.planMonths; + let expiresAt = before.expiresAt; + + if (dto.chargedAt !== undefined) { + chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : null; + } + if (dto.planMonths !== undefined) { + planMonths = dto.planMonths; + } + if ( + (dto.chargedAt !== undefined || dto.planMonths !== undefined) && + chargedAt && + planMonths + ) { + expiresAt = this.addMonths(chargedAt, planMonths); + } + + const plugin = await this.prisma.pluginSubscription.update({ + where: { id: pluginId }, + data: { + ...(dto.name !== undefined && { name: dto.name.trim() }), + ...(dto.specs !== undefined && { specs: dto.specs?.trim() || null }), + ...(dto.description !== undefined && { + description: dto.description?.trim() || null, + }), + ...(dto.priceRials !== undefined && { priceRials: dto.priceRials }), + ...(dto.chargedAt !== undefined && { chargedAt }), + ...(dto.planMonths !== undefined && { planMonths }), + ...(dto.chargedAt !== undefined || dto.planMonths !== undefined + ? { expiresAt } + : {}), + ...(dto.alertDaysBefore !== undefined && { + alertDaysBefore: dto.alertDaysBefore, + }), + ...(dto.isActive !== undefined && { isActive: dto.isActive }), + }, + }); + + await this.audit.log({ + action: 'plugin.update', + entityType: 'plugin_subscription', + entityId: plugin.id, + tenantId: plugin.tenantId, + userId, + before: this.publicPlugin(before) as unknown as Prisma.InputJsonValue, + after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue, + }); + + return this.getPlugin(plugin.id); + } + + async renew(pluginId: string, dto: RenewPluginDto, userId?: string) { + if (!PLUGIN_PLAN_MONTHS.includes(dto.planMonths as any)) { + throw new BadRequestException('مدت اشتراک نامعتبر است'); + } + + const before = await this.prisma.pluginSubscription.findUnique({ + where: { id: pluginId }, + include: { tenant: true }, + }); + if (!before) throw new NotFoundException('افزونه یافت نشد'); + + const chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : new Date(); + if (Number.isNaN(chargedAt.getTime())) { + throw new BadRequestException('تاریخ شارژ نامعتبر است'); + } + const expiresAt = this.addMonths(chargedAt, dto.planMonths); + + await this.prisma.$transaction([ + this.prisma.pluginSubscription.update({ + where: { id: pluginId }, + data: { + chargedAt, + planMonths: dto.planMonths, + priceRials: dto.priceRials, + expiresAt, + lastAlertDate: null, + isActive: true, + }, + }), + this.prisma.pluginCharge.create({ + data: { + pluginId, + chargedAt, + planMonths: dto.planMonths, + priceRials: dto.priceRials, + expiresAt, + note: dto.note?.trim() || null, + createdById: userId, + }, + }), + ]); + + await this.audit.log({ + action: 'plugin.renew', + entityType: 'plugin_subscription', + entityId: pluginId, + tenantId: before.tenantId, + userId, + after: { + name: before.name, + chargedAt: chargedAt.toISOString(), + planMonths: dto.planMonths, + priceRials: dto.priceRials, + expiresAt: expiresAt.toISOString(), + } as unknown as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + `سوپرادمین: افزونه «${before.name}» نسخه «${before.tenant.nameFa}» تمدید شد (${this.planLabel(dto.planMonths)}، تا ${formatIranDate(expiresAt)}).`, + ); + + return this.getPlugin(pluginId); + } + + async remove(pluginId: string, userId?: string) { + const before = await this.prisma.pluginSubscription.findUnique({ + where: { id: pluginId }, + include: { tenant: true }, + }); + if (!before) throw new NotFoundException('افزونه یافت نشد'); + + await this.prisma.pluginSubscription.delete({ where: { id: pluginId } }); + + await this.audit.log({ + action: 'plugin.delete', + entityType: 'plugin_subscription', + entityId: pluginId, + tenantId: before.tenantId, + userId, + before: this.publicPlugin(before) as unknown as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + `سوپرادمین: افزونه «${before.name}» از نسخه «${before.tenant.nameFa}» حذف شد.`, + ); + + return { ok: true }; + } + + async runExpiryAlerts() { + try { + const plugins = await this.prisma.pluginSubscription.findMany({ + where: { + isActive: true, + expiresAt: { not: null }, + tenant: { isActive: true }, + }, + include: { + tenant: { select: { nameFa: true, domain: true } }, + }, + }); + + const todayKey = iranYmd(new Date()); + let sent = 0; + + for (const plugin of plugins) { + if (!plugin.expiresAt) continue; + const daysLeft = iranDaysUntil(plugin.expiresAt); + const threshold = plugin.alertDaysBefore ?? 3; + if (daysLeft > threshold) continue; + if (plugin.lastAlertDate && iranYmd(plugin.lastAlertDate) === todayKey) { + continue; + } + + const when = + daysLeft < 0 + ? `${Math.abs(daysLeft)} روز است منقضی شده` + : daysLeft === 0 + ? 'امروز منقضی می‌شود' + : `${daysLeft} روز تا پایان اعتبار`; + + this.notify.notifyAdmins( + `هشدار افزونه: «${plugin.name}» روی «${plugin.tenant.nameFa}» (${plugin.tenant.domain}) — ${when} (انقضا: ${formatIranDate(plugin.expiresAt)}).`, + ); + + await this.prisma.pluginSubscription.update({ + where: { id: plugin.id }, + data: { lastAlertDate: new Date() }, + }); + sent += 1; + } + + if (sent > 0) this.logger.log(`Plugin expiry alerts sent: ${sent}`); + } catch (error: any) { + this.logger.error(`Plugin alert failed: ${error?.message || error}`); + } + } + + private toSummary(plugin: PluginSubscription) { + if (!plugin.expiresAt) { + return { + name: plugin.name, + isActive: plugin.isActive, + priceRials: plugin.priceRials, + chargedAt: plugin.chargedAt?.toISOString() ?? null, + planMonths: plugin.planMonths, + expiresAt: null as string | null, + daysLeft: null as number | null, + expiryLevel: 'unknown' as ExpiryLevel, + expiryLabel: 'بدون تاریخ اعتبار', + planLabel: plugin.planMonths ? this.planLabel(plugin.planMonths) : null, + }; + } + + const daysLeft = iranDaysUntil(plugin.expiresAt); + const threshold = plugin.alertDaysBefore ?? 3; + let expiryLevel: ExpiryLevel = 'healthy'; + let expiryLabel = `${daysLeft} روز مانده`; + + if (daysLeft < 0) { + expiryLevel = 'critical'; + expiryLabel = `${Math.abs(daysLeft)} روز گذشته از انقضا`; + } else if (daysLeft <= threshold) { + expiryLevel = 'critical'; + expiryLabel = + daysLeft === 0 ? 'امروز منقضی می‌شود' : `${daysLeft} روز مانده`; + } else if (daysLeft <= threshold + 4) { + expiryLevel = 'warning'; + expiryLabel = `${daysLeft} روز مانده`; + } + + return { + name: plugin.name, + isActive: plugin.isActive, + priceRials: plugin.priceRials, + chargedAt: plugin.chargedAt?.toISOString() ?? null, + planMonths: plugin.planMonths, + expiresAt: plugin.expiresAt.toISOString(), + daysLeft, + expiryLevel, + expiryLabel, + planLabel: plugin.planMonths ? this.planLabel(plugin.planMonths) : null, + }; + } + + private worstLevel(levels: ExpiryLevel[]): ExpiryLevel { + if (!levels.length) return 'none'; + const order: ExpiryLevel[] = ['critical', 'warning', 'unknown', 'healthy', 'none']; + for (const level of order) { + if (levels.includes(level)) return level; + } + return 'none'; + } + + private publicPlugin(plugin: PluginSubscription) { + return { + id: plugin.id, + tenantId: plugin.tenantId, + name: plugin.name, + specs: plugin.specs, + priceRials: plugin.priceRials, + chargedAt: plugin.chargedAt?.toISOString() ?? null, + planMonths: plugin.planMonths, + expiresAt: plugin.expiresAt?.toISOString() ?? null, + description: plugin.description, + alertDaysBefore: plugin.alertDaysBefore, + lastAlertDate: plugin.lastAlertDate?.toISOString() ?? null, + isActive: plugin.isActive, + updatedAt: plugin.updatedAt.toISOString(), + }; + } + + private planLabel(months: number) { + switch (months) { + case 1: + return 'یک‌ماهه'; + case 3: + return 'سه‌ماهه'; + case 6: + return 'شش‌ماهه'; + case 12: + return 'یک‌ساله'; + default: + return `${months} ماهه`; + } + } + + private addMonths(from: Date, months: number) { + const d = new Date(from.getTime()); + const day = d.getDate(); + d.setMonth(d.getMonth() + months); + if (d.getDate() < day) d.setDate(0); + return d; + } + + private async requireTenant(tenantId: string) { + const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); + if (!tenant) throw new NotFoundException('نسخه یافت نشد'); + return tenant; + } +} diff --git a/src/servers/dto/server-account.dto.ts b/src/servers/dto/server-account.dto.ts new file mode 100644 index 0000000..d71171b --- /dev/null +++ b/src/servers/dto/server-account.dto.ts @@ -0,0 +1,81 @@ +import { + IsBoolean, + IsIn, + IsInt, + IsISO8601, + IsOptional, + IsString, + Max, + MaxLength, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; + +const PLAN_MONTHS = [1, 3, 6, 12] as const; + +export class UpdateServerAccountDto { + @IsOptional() + @IsBoolean() + isDedicated?: boolean; + + @IsOptional() + @IsString() + @MaxLength(120) + serverName?: string | null; + + @IsOptional() + @IsString() + @MaxLength(2000) + specs?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(0) + priceRials?: number | null; + + @IsOptional() + @IsISO8601() + chargedAt?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @IsIn(PLAN_MONTHS as unknown as number[]) + planMonths?: number | null; + + @IsOptional() + @IsString() + @MaxLength(4000) + description?: string | null; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(30) + alertDaysBefore?: number; +} + +export class RenewServerDto { + @IsOptional() + @IsISO8601() + chargedAt?: string; + + @Type(() => Number) + @IsInt() + @IsIn(PLAN_MONTHS as unknown as number[]) + planMonths!: number; + + @Type(() => Number) + @IsInt() + @Min(0) + priceRials!: number; + + @IsOptional() + @IsString() + @MaxLength(500) + note?: string; +} + +export { PLAN_MONTHS }; diff --git a/src/servers/servers.controller.ts b/src/servers/servers.controller.ts new file mode 100644 index 0000000..fd258c1 --- /dev/null +++ b/src/servers/servers.controller.ts @@ -0,0 +1,58 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, + UseGuards, +} from '@nestjs/common'; +import { JwtAuthGuard } from '../auth/jwt-auth.guard'; +import { CurrentUser } from '../auth/current-user.decorator'; +import { RenewServerDto, UpdateServerAccountDto } from './dto/server-account.dto'; +import { ServersService } from './servers.service'; + +@Controller('servers') +@UseGuards(JwtAuthGuard) +export class ServersController { + constructor(private readonly serversService: ServersService) {} + + @Get('tenants') + listTenants() { + return this.serversService.listTenants(); + } + + @Get('expiring') + expiring(@Query('limit') limit?: string) { + return this.serversService.getExpiringSoon(Number(limit) || 5); + } + + @Post('check-alerts') + checkAlerts() { + return this.serversService.runExpiryAlerts().then(() => ({ ok: true })); + } + + @Get('tenants/:id') + getAccount(@Param('id') id: string) { + return this.serversService.getAccount(id); + } + + @Patch('tenants/:id') + updateAccount( + @Param('id') id: string, + @Body() dto: UpdateServerAccountDto, + @CurrentUser() user: { id: string }, + ) { + return this.serversService.updateAccount(id, dto, user.id); + } + + @Post('tenants/:id/renew') + renew( + @Param('id') id: string, + @Body() dto: RenewServerDto, + @CurrentUser() user: { id: string }, + ) { + return this.serversService.renew(id, dto, user.id); + } +} diff --git a/src/servers/servers.module.ts b/src/servers/servers.module.ts new file mode 100644 index 0000000..5df092b --- /dev/null +++ b/src/servers/servers.module.ts @@ -0,0 +1,12 @@ +import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; +import { ServersController } from './servers.controller'; +import { ServersService } from './servers.service'; + +@Module({ + imports: [NotificationsModule], + controllers: [ServersController], + providers: [ServersService], + exports: [ServersService], +}) +export class ServersModule {} diff --git a/src/servers/servers.service.ts b/src/servers/servers.service.ts new file mode 100644 index 0000000..3a9acce --- /dev/null +++ b/src/servers/servers.service.ts @@ -0,0 +1,505 @@ +import { + BadRequestException, + Injectable, + Logger, + NotFoundException, + OnModuleDestroy, + OnModuleInit, +} from '@nestjs/common'; +import { Prisma, ServerAccount } from '@prisma/client'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditService } from '../audit/audit.service'; +import { AdminNotifyService } from '../notifications/admin-notify.service'; +import { + PLAN_MONTHS, + RenewServerDto, + UpdateServerAccountDto, +} from './dto/server-account.dto'; +import { + formatIranDate, + iranDaysUntil, + iranYmd, +} from '../common/iran-time.util'; + +export type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown'; + +@Injectable() +export class ServersService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(ServersService.name); + private alertTimer: NodeJS.Timeout | null = null; + + constructor( + private readonly prisma: PrismaService, + private readonly audit: AuditService, + private readonly notify: AdminNotifyService, + ) {} + + onModuleInit() { + // Check once shortly after boot, then every 6 hours + setTimeout(() => void this.runExpiryAlerts(), 15_000); + this.alertTimer = setInterval( + () => void this.runExpiryAlerts(), + 6 * 60 * 60 * 1000, + ); + } + + onModuleDestroy() { + if (this.alertTimer) clearInterval(this.alertTimer); + } + + async listTenants() { + await this.ensureAccountsForAllTenants(); + void this.runExpiryAlerts(); + + const rows = await this.prisma.tenant.findMany({ + where: { isActive: true }, + orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], + include: { serverAccount: true }, + }); + + return rows.map((tenant) => { + const account = tenant.serverAccount!; + const summary = this.toSummary(account); + return { + id: tenant.id, + product: tenant.product, + slug: tenant.slug, + nameFa: tenant.nameFa, + nameEn: tenant.nameEn, + domain: tenant.domain, + avatarUrl: tenant.avatarUrl, + isActive: tenant.isActive, + server: summary, + }; + }); + } + + async getExpiringSoon(limit = 5) { + const rows = await this.listTenants(); + return rows + .filter((r) => !r.server.isDedicated && r.server.expiresAt) + .sort((a, b) => { + const ae = a.server.expiresAt ? Date.parse(a.server.expiresAt) : Infinity; + const be = b.server.expiresAt ? Date.parse(b.server.expiresAt) : Infinity; + return ae - be; + }) + .filter((r) => { + const level = r.server.expiryLevel; + return level === 'critical' || level === 'warning' || level === 'unknown'; + }) + .slice(0, limit); + } + + async getAccount(tenantId: string) { + const tenant = await this.requireTenant(tenantId); + const account = await this.ensureAccount(tenant.id); + const charges = await this.prisma.serverCharge.findMany({ + where: { serverAccountId: account.id }, + orderBy: { createdAt: 'desc' }, + take: 20, + }); + + return { + tenant: { + id: tenant.id, + product: tenant.product, + slug: tenant.slug, + nameFa: tenant.nameFa, + nameEn: tenant.nameEn, + domain: tenant.domain, + avatarUrl: tenant.avatarUrl, + }, + account: this.publicAccount(account), + summary: this.toSummary(account), + charges, + planOptions: PLAN_MONTHS.map((m) => ({ + months: m, + label: this.planLabel(m), + })), + }; + } + + async updateAccount( + tenantId: string, + dto: UpdateServerAccountDto, + userId?: string, + ) { + const account = await this.ensureAccount(tenantId); + const before = { ...account }; + + if (dto.planMonths != null && !PLAN_MONTHS.includes(dto.planMonths as any)) { + throw new BadRequestException('مدت شارژ نامعتبر است'); + } + + let expiresAt = account.expiresAt; + let chargedAt = account.chargedAt; + let planMonths = account.planMonths; + + if (dto.isDedicated === true) { + // Dedicated: keep history but stop managing/alerting on current period fields optionally + // We keep expiresAt as-is for record, but alerts skip dedicated. + } + + if (dto.chargedAt !== undefined) { + chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : null; + } + if (dto.planMonths !== undefined) { + planMonths = dto.planMonths; + } + + // Recalc expiry when charge date or plan changes (and not dedicated-only clear) + if ( + dto.isDedicated !== true && + (dto.chargedAt !== undefined || dto.planMonths !== undefined) && + chargedAt && + planMonths + ) { + expiresAt = this.addMonths(chargedAt, planMonths); + } + + if (dto.isDedicated === true) { + // Clearing managed alert state when switching to dedicated + } + + const updated = await this.prisma.serverAccount.update({ + where: { id: account.id }, + data: { + isDedicated: dto.isDedicated ?? undefined, + serverName: + dto.serverName === undefined + ? undefined + : dto.serverName?.trim() || null, + specs: dto.specs === undefined ? undefined : dto.specs?.trim() || null, + priceRials: dto.priceRials === undefined ? undefined : dto.priceRials, + chargedAt: + dto.chargedAt === undefined + ? undefined + : dto.chargedAt + ? new Date(dto.chargedAt) + : null, + planMonths: dto.planMonths === undefined ? undefined : dto.planMonths, + expiresAt: + dto.chargedAt !== undefined || dto.planMonths !== undefined + ? expiresAt + : undefined, + description: + dto.description === undefined + ? undefined + : dto.description?.trim() || null, + alertDaysBefore: dto.alertDaysBefore ?? undefined, + ...(dto.isDedicated === true + ? { lastAlertDate: null } + : {}), + }, + }); + + // If we toggled off dedicated and have charge+plan, ensure expiresAt + if ( + updated.isDedicated === false && + updated.chargedAt && + updated.planMonths && + !updated.expiresAt + ) { + const fixed = await this.prisma.serverAccount.update({ + where: { id: updated.id }, + data: { + expiresAt: this.addMonths(updated.chargedAt, updated.planMonths), + }, + }); + Object.assign(updated, fixed); + } + + await this.audit.log({ + action: 'server.account.update', + entityType: 'server_account', + entityId: updated.id, + tenantId, + userId, + before: this.publicAccount(before) as unknown as Prisma.InputJsonValue, + after: this.publicAccount(updated) as unknown as Prisma.InputJsonValue, + }); + + const tenant = await this.requireTenant(tenantId); + if (before.isDedicated !== updated.isDedicated) { + this.notify.notifyAdmins( + updated.isDedicated + ? `سوپرادمین: سرور «${tenant.nameFa}» روی حالت اختصاصی قرار گرفت.` + : `سوپرادمین: سرور «${tenant.nameFa}» از حالت اختصاصی خارج شد (مدیریت با ما).`, + ); + } + + return this.getAccount(tenantId); + } + + async renew(tenantId: string, dto: RenewServerDto, userId?: string) { + if (!PLAN_MONTHS.includes(dto.planMonths as any)) { + throw new BadRequestException('مدت شارژ نامعتبر است'); + } + if (dto.priceRials < 0) { + throw new BadRequestException('قیمت نامعتبر است'); + } + + const tenant = await this.requireTenant(tenantId); + const account = await this.ensureAccount(tenantId); + + if (account.isDedicated) { + throw new BadRequestException( + 'این نسخه سرور اختصاصی است؛ شارژ مدیریت‌شده لازم نیست. ابتدا سوئیچ اختصاصی را خاموش کنید.', + ); + } + + const chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : new Date(); + if (Number.isNaN(chargedAt.getTime())) { + throw new BadRequestException('تاریخ شارژ نامعتبر است'); + } + const expiresAt = this.addMonths(chargedAt, dto.planMonths); + + const [updated] = await this.prisma.$transaction([ + this.prisma.serverAccount.update({ + where: { id: account.id }, + data: { + chargedAt, + planMonths: dto.planMonths, + priceRials: dto.priceRials, + expiresAt, + lastAlertDate: null, + }, + }), + this.prisma.serverCharge.create({ + data: { + serverAccountId: account.id, + chargedAt, + planMonths: dto.planMonths, + priceRials: dto.priceRials, + expiresAt, + note: dto.note?.trim() || null, + createdById: userId, + }, + }), + ]); + + await this.audit.log({ + action: 'server.renew', + entityType: 'server_account', + entityId: account.id, + tenantId, + userId, + after: { + chargedAt: chargedAt.toISOString(), + planMonths: dto.planMonths, + priceRials: dto.priceRials, + expiresAt: expiresAt.toISOString(), + note: dto.note || null, + } as unknown as Prisma.InputJsonValue, + }); + + this.notify.notifyAdmins( + `سوپرادمین: سرور «${tenant.nameFa}» شارژ/تمدید شد (${this.planLabel(dto.planMonths)}، تا ${formatIranDate(expiresAt)}).`, + ); + + return this.getAccount(tenantId); + } + + async runExpiryAlerts() { + try { + const accounts = await this.prisma.serverAccount.findMany({ + where: { + isDedicated: false, + expiresAt: { not: null }, + tenant: { isActive: true }, + }, + include: { + tenant: { select: { id: true, nameFa: true, domain: true } }, + }, + }); + + const todayKey = iranYmd(new Date()); + let sent = 0; + + for (const account of accounts) { + if (!account.expiresAt) continue; + const daysLeft = iranDaysUntil(account.expiresAt); + const threshold = account.alertDaysBefore ?? 3; + + if (daysLeft > threshold) continue; + + if ( + account.lastAlertDate && + iranYmd(account.lastAlertDate) === todayKey + ) { + continue; + } + + const when = + daysLeft < 0 + ? `${Math.abs(daysLeft)} روز است منقضی شده` + : daysLeft === 0 + ? 'امروز منقضی می‌شود' + : `${daysLeft} روز تا پایان اعتبار`; + + this.notify.notifyAdmins( + `هشدار سرور: «${account.tenant.nameFa}» (${account.tenant.domain}) — ${when} (انقضا: ${formatIranDate(account.expiresAt)}). لطفاً شارژ/تمدید کنید.`, + ); + + await this.prisma.serverAccount.update({ + where: { id: account.id }, + data: { lastAlertDate: new Date() }, + }); + sent += 1; + } + + if (sent > 0) { + this.logger.log(`Server expiry alerts sent: ${sent}`); + } + } catch (error: any) { + this.logger.error(`Expiry alert failed: ${error?.message || error}`); + } + } + + private toSummary(account: ServerAccount) { + if (account.isDedicated) { + return { + isDedicated: true, + serverName: account.serverName, + specs: account.specs, + priceRials: account.priceRials, + chargedAt: account.chargedAt?.toISOString() ?? null, + planMonths: account.planMonths, + expiresAt: account.expiresAt?.toISOString() ?? null, + daysLeft: null as number | null, + expiryLevel: 'dedicated' as ExpiryLevel, + expiryLabel: 'اختصاصی', + planLabel: account.planMonths + ? this.planLabel(account.planMonths) + : null, + }; + } + + if (!account.expiresAt) { + return { + isDedicated: false, + serverName: account.serverName, + specs: account.specs, + priceRials: account.priceRials, + chargedAt: account.chargedAt?.toISOString() ?? null, + planMonths: account.planMonths, + expiresAt: null, + daysLeft: null, + expiryLevel: 'unknown' as ExpiryLevel, + expiryLabel: 'بدون تاریخ اعتبار', + planLabel: account.planMonths + ? this.planLabel(account.planMonths) + : null, + }; + } + + const daysLeft = iranDaysUntil(account.expiresAt); + const threshold = account.alertDaysBefore ?? 3; + let expiryLevel: ExpiryLevel = 'healthy'; + let expiryLabel = `${daysLeft} روز مانده`; + + if (daysLeft < 0) { + expiryLevel = 'critical'; + expiryLabel = `${Math.abs(daysLeft)} روز گذشته از انقضا`; + } else if (daysLeft <= threshold) { + expiryLevel = 'critical'; + expiryLabel = + daysLeft === 0 ? 'امروز منقضی می‌شود' : `${daysLeft} روز مانده`; + } else if (daysLeft <= threshold + 4) { + expiryLevel = 'warning'; + expiryLabel = `${daysLeft} روز مانده`; + } + + return { + isDedicated: false, + serverName: account.serverName, + specs: account.specs, + priceRials: account.priceRials, + chargedAt: account.chargedAt?.toISOString() ?? null, + planMonths: account.planMonths, + expiresAt: account.expiresAt.toISOString(), + daysLeft, + expiryLevel, + expiryLabel, + planLabel: account.planMonths ? this.planLabel(account.planMonths) : null, + }; + } + + private publicAccount(account: ServerAccount) { + return { + id: account.id, + tenantId: account.tenantId, + isDedicated: account.isDedicated, + serverName: account.serverName, + specs: account.specs, + priceRials: account.priceRials, + chargedAt: account.chargedAt?.toISOString() ?? null, + planMonths: account.planMonths, + expiresAt: account.expiresAt?.toISOString() ?? null, + description: account.description, + alertDaysBefore: account.alertDaysBefore, + lastAlertDate: account.lastAlertDate?.toISOString() ?? null, + updatedAt: account.updatedAt.toISOString(), + }; + } + + private planLabel(months: number) { + switch (months) { + case 1: + return 'یک‌ماهه'; + case 3: + return 'سه‌ماهه'; + case 6: + return 'شش‌ماهه'; + case 12: + return 'یک‌ساله'; + default: + return `${months} ماهه`; + } + } + + private addMonths(from: Date, months: number) { + const d = new Date(from.getTime()); + const day = d.getDate(); + d.setMonth(d.getMonth() + months); + // Handle month-end overflow (e.g. Jan 31 + 1 month) + if (d.getDate() < day) { + d.setDate(0); + } + return d; + } + + private async ensureAccountsForAllTenants() { + const tenants = await this.prisma.tenant.findMany({ + where: { serverAccount: null }, + select: { id: true }, + }); + for (const tenant of tenants) { + await this.ensureAccount(tenant.id); + } + } + + private async ensureAccount(tenantId: string) { + await this.requireTenant(tenantId); + const existing = await this.prisma.serverAccount.findUnique({ + where: { tenantId }, + }); + if (existing) return existing; + + return this.prisma.serverAccount.create({ + data: { + tenantId, + isDedicated: false, + alertDaysBefore: 3, + }, + }); + } + + private async requireTenant(tenantId: string) { + const tenant = await this.prisma.tenant.findUnique({ + where: { id: tenantId }, + }); + if (!tenant) throw new NotFoundException('نسخه یافت نشد'); + return tenant; + } +} diff --git a/src/sms/dto/sms-account.dto.ts b/src/sms/dto/sms-account.dto.ts index 21fdbb0..f3d9ae2 100644 --- a/src/sms/dto/sms-account.dto.ts +++ b/src/sms/dto/sms-account.dto.ts @@ -1,4 +1,13 @@ -import { IsBoolean, IsInt, IsObject, IsOptional, IsString, Min } from 'class-validator'; +import { + IsBoolean, + IsInt, + IsObject, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; +import { Type } from 'class-transformer'; export class UpdateSmsAccountDto { @IsOptional() @@ -61,3 +70,48 @@ export class ChargeSmsWalletDto { @IsString() note?: string; } + +export class UpdateSmsChargeInfoDto { + @IsOptional() + @IsString() + chargeCard?: string | null; + + @IsOptional() + @IsString() + chargeIban?: string | null; + + @IsOptional() + @IsString() + chargeHolder?: string | null; + + @IsOptional() + @IsString() + chargeInstruction?: string | null; +} + +export class SmsTableQueryDto { + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + page?: number; + + @IsOptional() + @Type(() => Number) + @IsInt() + @Min(1) + @Max(100) + pageSize?: number; + + @IsOptional() + @IsString() + fromDate?: string; + + @IsOptional() + @IsString() + toDate?: string; + + @IsOptional() + @IsString() + search?: string; +} diff --git a/src/sms/sms.controller.ts b/src/sms/sms.controller.ts index e2ba1b8..d55a612 100644 --- a/src/sms/sms.controller.ts +++ b/src/sms/sms.controller.ts @@ -1,7 +1,22 @@ -import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Put, + Query, + UseGuards, +} from '@nestjs/common'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; -import { ChargeSmsWalletDto, UpdateSmsAccountDto } from './dto/sms-account.dto'; +import { + ChargeSmsWalletDto, + SmsTableQueryDto, + UpdateSmsAccountDto, + UpdateSmsChargeInfoDto, +} from './dto/sms-account.dto'; import { SmsService } from './sms.service'; @Controller('sms') @@ -24,6 +39,48 @@ export class SmsController { return this.smsService.getAccount(id); } + @Get('tenants/:id/charge-info') + getChargeInfo(@Param('id') id: string) { + return this.smsService.getChargeInfo(id); + } + + @Put('tenants/:id/charge-info') + updateChargeInfo( + @Param('id') id: string, + @Body() dto: UpdateSmsChargeInfoDto, + @CurrentUser() user: { id: string }, + ) { + return this.smsService.updateChargeInfo(id, dto, user.id); + } + + @Get('tenants/:id/report') + report( + @Param('id') id: string, + @Query('fromDate') fromDate?: string, + @Query('toDate') toDate?: string, + ) { + return this.smsService.getPeriodReport(id, fromDate, toDate); + } + + @Get('tenants/:id/report/charges') + chargeReport( + @Param('id') id: string, + @Query('fromDate') fromDate?: string, + @Query('toDate') toDate?: string, + ) { + return this.smsService.getChargeReport(id, fromDate, toDate); + } + + @Post('tenants/:id/logs/table') + logsTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) { + return this.smsService.getLogsTable(id, dto); + } + + @Post('tenants/:id/ledger/table') + ledgerTable(@Param('id') id: string, @Body() dto: SmsTableQueryDto) { + return this.smsService.getLedgerTable(id, dto); + } + @Patch('tenants/:id') updateAccount( @Param('id') id: string, diff --git a/src/sms/sms.module.ts b/src/sms/sms.module.ts index 27f7b45..6e6f825 100644 --- a/src/sms/sms.module.ts +++ b/src/sms/sms.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; import { SmsController } from './sms.controller'; import { SmsService } from './sms.service'; @Module({ + imports: [NotificationsModule], controllers: [SmsController], providers: [SmsService], exports: [SmsService], diff --git a/src/sms/sms.service.ts b/src/sms/sms.service.ts index 814583e..d1e140c 100644 --- a/src/sms/sms.service.ts +++ b/src/sms/sms.service.ts @@ -3,10 +3,17 @@ import { Injectable, NotFoundException, } from '@nestjs/common'; -import { Prisma, SmsAccount } from '@prisma/client'; +import { Prisma, SmsAccount, SmsLedgerType } from '@prisma/client'; import { PrismaService } from '../prisma/prisma.service'; import { AuditService } from '../audit/audit.service'; -import { ChargeSmsWalletDto, UpdateSmsAccountDto } from './dto/sms-account.dto'; +import { AdminNotifyService } from '../notifications/admin-notify.service'; +import { iranYmd } from '../common/iran-time.util'; +import { + ChargeSmsWalletDto, + SmsTableQueryDto, + UpdateSmsAccountDto, + UpdateSmsChargeInfoDto, +} from './dto/sms-account.dto'; type BalanceLevel = 'healthy' | 'warning' | 'critical' | 'unknown'; @@ -15,6 +22,7 @@ export class SmsService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, + private readonly notify: AdminNotifyService, ) {} async listTenants() { @@ -38,7 +46,7 @@ export class SmsService { avatarUrl: tenant.avatarUrl, isActive: tenant.isActive, isBlocked: account.isBlocked, - sms: this.toSummary(account), + sms: this.toSummaryQuick(account), }; }); } @@ -69,35 +77,54 @@ export class SmsService { domain: tenant.domain, avatarUrl: tenant.avatarUrl, }, - account: { - id: account.id, - balanceRials: account.balanceRials, - pricePerPartRials: account.pricePerPartRials, - isBlocked: account.isBlocked, - alertCriticalRials: account.alertCriticalRials, - alertWarningRials: account.alertWarningRials, - gatewayProvider: account.gatewayProvider, - melipayamakUser: account.melipayamakUser, - melipayamakPassword: account.melipayamakPassword, - melipayamakFrom: account.melipayamakFrom, - panelUsername: account.panelUsername, - panelPassword: account.panelPassword, - panelFrom: account.panelFrom, - extraSettings: (account.extraSettings as Record) || {}, - todayMessages: account.todayMessages, - todayParts: account.todayParts, - todayCostRials: account.todayCostRials, - monthMessages: account.monthMessages, - monthParts: account.monthParts, - monthCostRials: account.monthCostRials, - }, - summary: this.toSummary(account), + account: this.publicAccount(account), + chargeInfo: this.chargeInfoOf(account), + summary: await this.toSummaryFromLogs(account), ledgers, safeNote: 'این پنل متعلق به سوپرادمین است و فعلاً پنل پیامکی داخل همکار/خانگی را تغییر نمی‌دهد.', }; } + async getChargeInfo(tenantId: string) { + const account = await this.ensureAccount(tenantId); + return this.chargeInfoOf(account); + } + + async updateChargeInfo( + tenantId: string, + dto: UpdateSmsChargeInfoDto, + userId?: string, + ) { + const account = await this.ensureAccount(tenantId); + const before = this.chargeInfoOf(account); + const updated = await this.prisma.smsAccount.update({ + where: { id: account.id }, + data: { + chargeCard: dto.chargeCard === undefined ? undefined : dto.chargeCard || null, + chargeIban: dto.chargeIban === undefined ? undefined : dto.chargeIban || null, + chargeHolder: + dto.chargeHolder === undefined ? undefined : dto.chargeHolder || null, + chargeInstruction: + dto.chargeInstruction === undefined + ? undefined + : dto.chargeInstruction || null, + }, + }); + + await this.audit.log({ + action: 'sms.charge_info.update', + entityType: 'sms_account', + entityId: updated.id, + tenantId, + userId, + before: before as unknown as Prisma.InputJsonValue, + after: this.chargeInfoOf(updated) as unknown as Prisma.InputJsonValue, + }); + + return this.chargeInfoOf(updated); + } + async updateAccount(tenantId: string, dto: UpdateSmsAccountDto, userId?: string) { const account = await this.ensureAccount(tenantId); const before = { ...account }; @@ -141,6 +168,15 @@ export class SmsService { after: this.publicAccount(updated) as unknown as Prisma.InputJsonValue, }); + if (before.isBlocked !== updated.isBlocked) { + const tenant = await this.requireTenant(tenantId); + this.notify.notifyAdmins( + updated.isBlocked + ? `سوپرادمین: ارسال SMS نسخه «${tenant.nameFa}» مسدود شد.` + : `سوپرادمین: مسدودسازی SMS نسخه «${tenant.nameFa}» برداشته شد.`, + ); + } + return this.getAccount(tenantId); } @@ -155,7 +191,7 @@ export class SmsService { throw new BadRequestException('موجودی نمی‌تواند منفی شود'); } - const type = dto.amountRials > 0 ? 'charge' : 'adjust'; + const type: SmsLedgerType = dto.amountRials > 0 ? 'charge' : 'adjust'; const [updated] = await this.prisma.$transaction([ this.prisma.smsAccount.update({ @@ -167,6 +203,7 @@ export class SmsService { smsAccountId: account.id, type, amountRials: dto.amountRials, + balanceAfterRials: nextBalance, note: dto.note || (dto.amountRials > 0 ? 'شارژ کیف پول' : 'اصلاح موجودی'), createdById: userId, }, @@ -186,6 +223,12 @@ export class SmsService { } as unknown as Prisma.InputJsonValue, }); + const tenant = await this.requireTenant(tenantId); + const amountLabel = dto.amountRials.toLocaleString('fa-IR'); + this.notify.notifyAdmins( + `سوپرادمین: کیف پول SMS «${tenant.nameFa}» ${dto.amountRials > 0 ? 'شارژ' : 'اصلاح'} شد (${amountLabel} ریال). موجودی: ${updated.balanceRials.toLocaleString('fa-IR')}`, + ); + return this.getAccount(tenantId); } @@ -193,6 +236,187 @@ export class SmsService { return this.updateAccount(tenantId, { isBlocked }, userId); } + async getPeriodReport(tenantId: string, fromDate?: string, toDate?: string) { + const account = await this.ensureAccount(tenantId); + const range = this.resolveDateRange(fromDate, toDate, true); + + const logs = await this.prisma.smsSendLog.findMany({ + where: { + smsAccountId: account.id, + createdAt: { gte: range.from, lte: range.to }, + }, + select: { + parts: true, + totalAmountRials: true, + provider: true, + }, + }); + + const byProviderMap = new Map< + string, + { provider: string; messages: number; parts: number; amount: number } + >(); + let messages = 0; + let parts = 0; + let amount = 0; + + for (const row of logs) { + messages += 1; + parts += row.parts; + amount += row.totalAmountRials; + const key = row.provider || 'نامشخص'; + const cur = byProviderMap.get(key) || { + provider: key, + messages: 0, + parts: 0, + amount: 0, + }; + cur.messages += 1; + cur.parts += row.parts; + cur.amount += row.totalAmountRials; + byProviderMap.set(key, cur); + } + + return { + fromDate: iranYmd(range.from), + toDate: iranYmd(range.to), + balance: account.balanceRials, + summary: { messages, parts, amount }, + byProvider: [...byProviderMap.values()].sort((a, b) => b.amount - a.amount), + }; + } + + async getChargeReport(tenantId: string, fromDate?: string, toDate?: string) { + const account = await this.ensureAccount(tenantId); + const range = this.resolveDateRange(fromDate, toDate, false); + + const where: Prisma.SmsLedgerWhereInput = { + smsAccountId: account.id, + type: 'charge', + }; + if (range) { + where.createdAt = { gte: range.from, lte: range.to }; + } + + const items = await this.prisma.smsLedger.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: 200, + }); + + return { + fromDate: range ? iranYmd(range.from) : null, + toDate: range ? iranYmd(range.to) : null, + summary: { + charges: items.length, + amount: items.reduce((sum, row) => sum + row.amountRials, 0), + }, + items: items.map((row) => ({ + id: row.id, + amount: row.amountRials, + balanceAfter: row.balanceAfterRials, + note: row.note, + createdBy: row.createdById, + createdAt: row.createdAt, + })), + }; + } + + async getLogsTable(tenantId: string, dto: SmsTableQueryDto) { + const account = await this.ensureAccount(tenantId); + const page = dto.page || 1; + const pageSize = dto.pageSize || 20; + const range = this.resolveDateRange(dto.fromDate, dto.toDate, false); + + const where: Prisma.SmsSendLogWhereInput = { + smsAccountId: account.id, + }; + if (range) { + where.createdAt = { gte: range.from, lte: range.to }; + } + if (dto.search?.trim()) { + const q = dto.search.trim(); + where.OR = [ + { receiver: { contains: q } }, + { body: { contains: q } }, + { provider: { contains: q } }, + { source: { contains: q } }, + ]; + } + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.smsSendLog.count({ where }), + this.prisma.smsSendLog.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + + return { + page, + pageSize, + total, + rows: rows.map((row) => ({ + id: row.id, + receiver: row.receiver, + body: row.body, + parts: row.parts, + pricePerPart: row.pricePerPartRials, + totalAmount: row.totalAmountRials, + provider: row.provider, + source: row.source, + isAlert: row.isAlert, + createdAt: row.createdAt, + })), + }; + } + + async getLedgerTable(tenantId: string, dto: SmsTableQueryDto) { + const account = await this.ensureAccount(tenantId); + const page = dto.page || 1; + const pageSize = dto.pageSize || 20; + const range = this.resolveDateRange(dto.fromDate, dto.toDate, false); + + const where: Prisma.SmsLedgerWhereInput = { + smsAccountId: account.id, + }; + if (range) { + where.createdAt = { gte: range.from, lte: range.to }; + } + if (dto.search?.trim()) { + where.note = { contains: dto.search.trim() }; + } + + const [total, rows] = await this.prisma.$transaction([ + this.prisma.smsLedger.count({ where }), + this.prisma.smsLedger.findMany({ + where, + orderBy: { createdAt: 'desc' }, + skip: (page - 1) * pageSize, + take: pageSize, + }), + ]); + + return { + page, + pageSize, + total, + rows: rows.map((row) => ({ + id: row.id, + type: row.type, + amount: row.amountRials, + balanceAfter: row.balanceAfterRials, + pricePerPart: null as number | null, + parts: row.parts, + note: row.note, + createdBy: row.createdById, + createdAt: row.createdAt, + })), + }; + } + private async ensureAccountsForAllTenants() { const tenants = await this.prisma.tenant.findMany({ where: { smsAccount: null }, @@ -208,7 +432,6 @@ export class SmsService { const existing = await this.prisma.smsAccount.findUnique({ where: { tenantId } }); if (existing) return existing; - // Seed varied demo balances so color cards are visible until real charges exist const demoBalances = [250000, 85000, 7500, 150000, 12000, 3000, 500000, 45000]; const hash = [...tenantId].reduce((sum, ch) => sum + ch.charCodeAt(0), 0); const balanceRials = demoBalances[hash % demoBalances.length]; @@ -224,7 +447,7 @@ export class SmsService { }); } - private toSummary(account: SmsAccount) { + private toSummaryQuick(account: SmsAccount) { const level = this.balanceLevel( account.balanceRials, account.alertCriticalRials, @@ -247,6 +470,47 @@ export class SmsService { }; } + private async toSummaryFromLogs(account: SmsAccount) { + const usage = await this.computeUsageStats(account.id); + const base = this.toSummaryQuick(account); + return { + ...base, + todayMessages: usage.today.messages, + todayParts: usage.today.parts, + todayCostRials: usage.today.amount, + monthMessages: usage.month.messages, + monthParts: usage.month.parts, + monthCostRials: usage.month.amount, + }; + } + + private async computeUsageStats(smsAccountId: string) { + const todayYmd = iranYmd(new Date()); + const todayStart = new Date(`${todayYmd}T00:00:00+03:30`); + const monthStartYmd = `${todayYmd.slice(0, 8)}01`; + const monthStart = new Date(`${monthStartYmd}T00:00:00+03:30`); + const now = new Date(); + + const [todayLogs, monthLogs] = await Promise.all([ + this.prisma.smsSendLog.findMany({ + where: { smsAccountId, createdAt: { gte: todayStart, lte: now } }, + select: { parts: true, totalAmountRials: true }, + }), + this.prisma.smsSendLog.findMany({ + where: { smsAccountId, createdAt: { gte: monthStart, lte: now } }, + select: { parts: true, totalAmountRials: true }, + }), + ]); + + const fold = (rows: { parts: number; totalAmountRials: number }[]) => ({ + messages: rows.length, + parts: rows.reduce((s, r) => s + r.parts, 0), + amount: rows.reduce((s, r) => s + r.totalAmountRials, 0), + }); + + return { today: fold(todayLogs), month: fold(monthLogs) }; + } + private balanceLevel( balanceRials: number, critical = 10000, @@ -261,18 +525,63 @@ export class SmsService { return `${new Intl.NumberFormat('fa-IR').format(value)} ریال`; } + private chargeInfoOf(account: SmsAccount) { + return { + card: account.chargeCard || '', + iban: account.chargeIban || '', + accountHolder: account.chargeHolder || '', + instruction: account.chargeInstruction || '', + }; + } + private publicAccount(account: SmsAccount) { return { + id: account.id, balanceRials: account.balanceRials, pricePerPartRials: account.pricePerPartRials, isBlocked: account.isBlocked, alertCriticalRials: account.alertCriticalRials, alertWarningRials: account.alertWarningRials, - gatewayProvider: account.gatewayProvider, - melipayamakUser: account.melipayamakUser, - melipayamakFrom: account.melipayamakFrom, - panelUsername: account.panelUsername, - panelFrom: account.panelFrom, + chargeCard: account.chargeCard, + chargeIban: account.chargeIban, + chargeHolder: account.chargeHolder, + chargeInstruction: account.chargeInstruction, + }; + } + + /** + * @param defaultMonth when true and no dates: from month start to now (always returns a range) + * @returns null when optional and no dates provided + */ + private resolveDateRange( + fromDate: string | undefined, + toDate: string | undefined, + defaultMonth: true, + ): { from: Date; to: Date }; + private resolveDateRange( + fromDate?: string, + toDate?: string, + defaultMonth?: false, + ): { from: Date; to: Date } | null; + private resolveDateRange( + fromDate?: string, + toDate?: string, + defaultMonth = false, + ): { from: Date; to: Date } | null { + const today = iranYmd(new Date()); + if (!fromDate && !toDate) { + if (!defaultMonth) return null; + return { + from: new Date(`${today.slice(0, 8)}01T00:00:00+03:30`), + to: new Date(`${today}T23:59:59.999+03:30`), + }; + } + + const fromYmd = fromDate && /^\d{4}-\d{2}-\d{2}$/.test(fromDate) ? fromDate : today; + const toYmd = toDate && /^\d{4}-\d{2}-\d{2}$/.test(toDate) ? toDate : today; + return { + from: new Date(`${fromYmd}T00:00:00+03:30`), + to: new Date(`${toYmd}T23:59:59.999+03:30`), }; } diff --git a/src/tenants/dto/create-tenant.dto.ts b/src/tenants/dto/create-tenant.dto.ts index bfa0a4c..990b0c7 100644 --- a/src/tenants/dto/create-tenant.dto.ts +++ b/src/tenants/dto/create-tenant.dto.ts @@ -80,6 +80,11 @@ export class CreateTenantDto { @IsObject() appUiConfig?: Record; + @IsOptional() + @IsString() + @MaxLength(64) + appVersion?: string; + @IsOptional() @IsBoolean() isActive?: boolean; diff --git a/src/tenants/tenants.controller.ts b/src/tenants/tenants.controller.ts index 7485505..b556af6 100644 --- a/src/tenants/tenants.controller.ts +++ b/src/tenants/tenants.controller.ts @@ -1,4 +1,5 @@ import { + BadRequestException, Body, Controller, Delete, @@ -7,25 +8,73 @@ import { Patch, Post, Query, + UploadedFile, UseGuards, + UseInterceptors, } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; +import { FileInterceptor } from '@nestjs/platform-express'; import { ProductType } from '@prisma/client'; +import { diskStorage } from 'multer'; +import { extname } from 'path'; import { JwtAuthGuard } from '../auth/jwt-auth.guard'; import { CurrentUser } from '../auth/current-user.decorator'; +import { + AVATARS_DIR, + ensureUploadsDirs, + publicApiBase, +} from '../uploads/uploads.util'; import { CreateTenantDto } from './dto/create-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto'; import { TenantsService } from './tenants.service'; +const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i; + @Controller('tenants') @UseGuards(JwtAuthGuard) export class TenantsController { - constructor(private readonly tenantsService: TenantsService) {} + constructor( + private readonly tenantsService: TenantsService, + private readonly config: ConfigService, + ) {} @Get() findAll(@Query('product') product?: ProductType) { return this.tenantsService.findAll(product); } + @Post('upload-avatar') + @UseInterceptors( + FileInterceptor('file', { + storage: diskStorage({ + destination: (_req, _file, cb) => { + ensureUploadsDirs(); + cb(null, AVATARS_DIR); + }, + filename: (_req, file, cb) => { + const safeExt = extname(file.originalname).toLowerCase() || '.png'; + const name = `avatar_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${safeExt}`; + cb(null, name); + }, + }), + limits: { fileSize: 2 * 1024 * 1024 }, + fileFilter: (_req, file, cb) => { + if (!IMAGE_MIME.test(file.mimetype)) { + cb(new BadRequestException('فقط تصویر (jpg/png/gif/webp/svg) مجاز است') as any, false); + return; + } + cb(null, true); + }, + }), + ) + uploadAvatar(@UploadedFile() file: Express.Multer.File) { + if (!file) { + throw new BadRequestException('فایل ارسال نشده است'); + } + const base = publicApiBase(this.config); + return { url: `${base}/uploads/avatars/${file.filename}` }; + } + @Get(':id') findOne(@Param('id') id: string) { return this.tenantsService.findOne(id); diff --git a/src/tenants/tenants.module.ts b/src/tenants/tenants.module.ts index 522f740..06bce0e 100644 --- a/src/tenants/tenants.module.ts +++ b/src/tenants/tenants.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; import { TenantsController } from './tenants.controller'; import { TenantsService } from './tenants.service'; @Module({ + imports: [NotificationsModule], controllers: [TenantsController], providers: [TenantsService], exports: [TenantsService], diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 5826caf..21498c1 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -6,6 +6,7 @@ import { 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 { CreateTenantDto } from './dto/create-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto'; @@ -14,6 +15,7 @@ export class TenantsService { constructor( private readonly prisma: PrismaService, private readonly audit: AuditService, + private readonly notify: AdminNotifyService, ) {} async findAll(product?: ProductType) { @@ -36,6 +38,7 @@ export class TenantsService { appUrl: true, adminUrl: true, appUiConfig: true, + appVersion: true, isActive: true, notes: true, createdAt: true, @@ -78,6 +81,7 @@ export class TenantsService { appUiConfig: dto.appUiConfig ? (dto.appUiConfig as Prisma.InputJsonValue) : undefined, + appVersion: dto.appVersion?.trim() || '1', isActive: dto.isActive ?? true, notes: dto.notes, }, @@ -92,6 +96,10 @@ export class TenantsService { after: this.sanitize(tenant) as unknown as Prisma.InputJsonValue, }); + this.notify.notifyAdmins( + `سوپرادمین: نسخه جدید «${tenant.nameFa}» (${tenant.domain}) ایجاد شد.`, + ); + return this.sanitize(tenant); } catch (error) { this.handleUnique(error); @@ -136,6 +144,9 @@ export class TenantsService { ...(dto.appUiConfig !== undefined && { appUiConfig: dto.appUiConfig as Prisma.InputJsonValue, }), + ...(dto.appVersion !== undefined && { + appVersion: dto.appVersion.trim() || '1', + }), ...(dto.isActive !== undefined && { isActive: dto.isActive }), ...(dto.notes !== undefined && { notes: dto.notes }), }, @@ -151,6 +162,22 @@ export class TenantsService { after: this.sanitize(tenant) as unknown as Prisma.InputJsonValue, }); + const bits: string[] = []; + if (before.isActive !== tenant.isActive) { + bits.push(tenant.isActive ? 'فعال شد' : 'غیرفعال شد'); + } + if (before.appVersion !== tenant.appVersion) { + bits.push(`ورژن ${before.appVersion} → ${tenant.appVersion}`); + } + if (before.nameFa !== tenant.nameFa || before.domain !== tenant.domain) { + bits.push('هویت/دامنه تغییر کرد'); + } + this.notify.notifyAdmins( + bits.length + ? `سوپرادمین: نسخه «${tenant.nameFa}» — ${bits.join('؛ ')}` + : `سوپرادمین: نسخه «${tenant.nameFa}» به‌روزرسانی شد.`, + ); + return this.sanitize(tenant); } catch (error) { this.handleUnique(error); @@ -175,6 +202,10 @@ export class TenantsService { before: this.sanitize(before) as unknown as Prisma.InputJsonValue, }); + this.notify.notifyAdmins( + `سوپرادمین: نسخه «${before.nameFa}» (${before.domain}) حذف شد.`, + ); + return { ok: true }; }