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 <cursoragent@cursor.com>
This commit is contained in:
+15
-3
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Generated
+11
@@ -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",
|
||||
|
||||
+3
-1
@@ -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",
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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<string>()
|
||||
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<string, any> | 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()
|
||||
})
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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',
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
@@ -0,0 +1,5 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "tenants" ADD COLUMN "app_version" TEXT NOT NULL DEFAULT '1';
|
||||
|
||||
-- DropTable
|
||||
DROP TABLE IF EXISTS "product_releases";
|
||||
@@ -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;
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
@@ -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");
|
||||
+145
-12
@@ -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")
|
||||
}
|
||||
|
||||
+27
-67
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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('این شماره موبایل قبلاً ثبت شده است');
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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],
|
||||
})
|
||||
|
||||
+35
-10
@@ -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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+22
-10
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
@@ -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<string, any> | 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<string, any>): 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;
|
||||
|
||||
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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<string>();
|
||||
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];
|
||||
}
|
||||
}
|
||||
+46
-3
@@ -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<NestExpressApplication>(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<string>(
|
||||
'CORS_ORIGIN',
|
||||
'http://localhost:5173,http://localhost:3100',
|
||||
);
|
||||
const corsOrigins = corsOriginRaw
|
||||
.split(',')
|
||||
.map((item) => item.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
const isDev = (config.get<string>('NODE_ENV') ?? process.env.NODE_ENV) !== 'production';
|
||||
|
||||
app.enableCors({
|
||||
origin: config.get<string>('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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
@@ -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<CentralSmsResult> {
|
||||
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<string>('CENTRAL_SMS_URL') ||
|
||||
'http://185.231.112.101/api/createsms';
|
||||
const secret = this.config.get<string>('CENTRAL_SMS_SECRET')?.trim();
|
||||
if (!secret) {
|
||||
this.logger.error('CENTRAL_SMS_SECRET is not configured');
|
||||
return { success: false, error: 'سرویس پیامک پیکربندی نشده است' };
|
||||
}
|
||||
const host =
|
||||
this.config.get<string>('CENTRAL_SMS_HOST') || 'superadmin';
|
||||
const platform =
|
||||
this.config.get<string>('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 };
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { IsBoolean } from 'class-validator';
|
||||
|
||||
export class UpdateAdminSmsDto {
|
||||
@IsBoolean()
|
||||
enabled!: boolean;
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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<RuntimeSettings>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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],
|
||||
|
||||
+343
-34
@@ -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<string, string>) || {},
|
||||
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`),
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,11 @@ export class CreateTenantDto {
|
||||
@IsObject()
|
||||
appUiConfig?: Record<string, unknown>;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
appVersion?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
isActive?: boolean;
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user