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 { SettingsProxyService } from '../settings-proxy/settings-proxy.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'; const EXPIRY_RANK: Record = { critical: 0, warning: 1, unknown: 2, healthy: 3, dedicated: 4, }; @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, private readonly settingsProxy: SettingsProxyService, ) {} onModuleInit() { 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() { void this.runExpiryAlerts(); const rows = await this.prisma.tenant.findMany({ where: { isActive: true }, orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], include: { serverAccounts: { orderBy: { createdAt: 'asc' } }, }, }); return rows.map((tenant) => { const servers = tenant.serverAccounts.map((account) => this.toSummary(account), ); const worst = this.pickWorstSummary(servers); 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, serversCount: servers.length, servers, /** Worst status across servers — used for card color on list/home */ server: worst, }; }); } async getExpiringSoon(limit = 5) { const rows = await this.listTenants(); const flat = []; for (const tenant of rows) { for (const accountSummary of tenant.servers) { if (accountSummary.isDedicated) continue; flat.push({ id: tenant.id, product: tenant.product, slug: tenant.slug, nameFa: tenant.nameFa, domain: tenant.domain, serverAccountId: accountSummary.id, server: accountSummary, }); } } return flat .filter((r) => r.server.expiresAt || r.server.expiryLevel === 'unknown') .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 getTenantServers(tenantId: string) { const tenant = await this.requireTenant(tenantId); const accounts = await this.prisma.serverAccount.findMany({ where: { tenantId: tenant.id }, orderBy: { createdAt: 'asc' }, }); return { tenant: { id: tenant.id, product: tenant.product, slug: tenant.slug, nameFa: tenant.nameFa, nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, serverAccessBlocked: Boolean(tenant.serverAccessBlocked), serverAccessBlockedAt: tenant.serverAccessBlockedAt, }, servers: accounts.map((account) => ({ account: this.publicAccount(account), summary: this.toSummary(account), })), planOptions: PLAN_MONTHS.map((m) => ({ months: m, label: this.planLabel(m), })), }; } /** @deprecated alias kept for old clients — same as getTenantServers */ async getAccount(tenantId: string) { return this.getTenantServers(tenantId); } async getServerAccount(accountId: string) { const account = await this.requireAccount(accountId); const tenant = await this.requireTenant(account.tenantId); 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 createServerAccount( tenantId: string, dto: UpdateServerAccountDto, userId?: string, ) { const tenant = await this.requireTenant(tenantId); const created = await this.prisma.serverAccount.create({ data: { tenantId: tenant.id, isDedicated: dto.isDedicated ?? false, serverName: dto.serverName?.trim() || null, specs: dto.specs?.trim() || null, description: dto.description?.trim() || null, priceRials: dto.priceRials ?? null, alertDaysBefore: dto.alertDaysBefore ?? 3, planMonths: dto.planMonths ?? null, chargedAt: dto.chargedAt ? new Date(dto.chargedAt) : null, expiresAt: dto.isDedicated !== true && dto.chargedAt && dto.planMonths ? this.addMonths(new Date(dto.chargedAt), dto.planMonths) : null, }, }); await this.audit.log({ action: 'server.account.create', entityType: 'server_account', entityId: created.id, tenantId, userId, after: this.publicAccount(created) as unknown as Prisma.InputJsonValue, }); return this.getServerAccount(created.id); } async updateServerAccount( accountId: string, dto: UpdateServerAccountDto, userId?: string, ) { const account = await this.requireAccount(accountId); 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.chargedAt !== undefined) { chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : null; } if (dto.planMonths !== undefined) { planMonths = dto.planMonths; } if ( dto.isDedicated !== true && (dto.chargedAt !== undefined || dto.planMonths !== undefined) && chargedAt && planMonths ) { expiresAt = this.addMonths(chargedAt, planMonths); } 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 ( 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: updated.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(updated.tenantId); if (before.isDedicated !== updated.isDedicated) { const label = this.serverLabel(updated); this.notify.notifyAdmins( updated.isDedicated ? `سوپرادمین: سرور «${tenant.nameFa}» / ${label} روی حالت اختصاصی قرار گرفت.` : `سوپرادمین: سرور «${tenant.nameFa}» / ${label} از حالت اختصاصی خارج شد (مدیریت با ما).`, ); } return this.getServerAccount(updated.id); } /** Legacy: update first/only account by tenant id */ async updateAccount( tenantId: string, dto: UpdateServerAccountDto, userId?: string, ) { const accounts = await this.prisma.serverAccount.findMany({ where: { tenantId }, orderBy: { createdAt: 'asc' }, take: 1, }); if (!accounts.length) { return this.createServerAccount(tenantId, dto, userId); } return this.updateServerAccount(accounts[0].id, dto, userId); } async deleteServerAccount(accountId: string, userId?: string) { const account = await this.requireAccount(accountId); const tenantId = account.tenantId; const before = this.publicAccount(account); await this.prisma.serverAccount.delete({ where: { id: account.id } }); await this.audit.log({ action: 'server.account.delete', entityType: 'server_account', entityId: accountId, tenantId, userId, before: before as unknown as Prisma.InputJsonValue, }); return this.getTenantServers(tenantId); } async renewAccount(accountId: string, dto: RenewServerDto, userId?: string) { if (!PLAN_MONTHS.includes(dto.planMonths as any)) { throw new BadRequestException('مدت شارژ نامعتبر است'); } if (dto.priceRials < 0) { throw new BadRequestException('قیمت نامعتبر است'); } const account = await this.requireAccount(accountId); const tenant = await this.requireTenant(account.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); 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: tenant.id, userId, after: { chargedAt: chargedAt.toISOString(), planMonths: dto.planMonths, priceRials: dto.priceRials, expiresAt: expiresAt.toISOString(), note: dto.note || null, } as unknown as Prisma.InputJsonValue, }); const label = this.serverLabel(account); this.notify.notifyAdmins( `سوپرادمین: سرور «${tenant.nameFa}» / ${label} شارژ/تمدید شد (${this.planLabel(dto.planMonths)}، تا ${formatIranDate(expiresAt)}).`, ); return this.getServerAccount(account.id); } /** Legacy renew by tenant (first account) */ async renew(tenantId: string, dto: RenewServerDto, userId?: string) { const accounts = await this.prisma.serverAccount.findMany({ where: { tenantId }, orderBy: { createdAt: 'asc' }, take: 1, }); if (!accounts.length) { throw new BadRequestException('ابتدا یک سرور برای این نسخه ثبت کنید'); } return this.renewAccount(accounts[0].id, dto, userId); } 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} روز تا پایان اعتبار`; const label = this.serverLabel(account); this.notify.notifyAdmins( `هشدار سرور: «${account.tenant.nameFa}» / ${label} (${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 pickWorstSummary( servers: Array>, ) { if (!servers.length) { return { id: null as string | null, isDedicated: false, serverName: null as string | null, specs: null as string | null, priceRials: null as number | null, chargedAt: null as string | null, planMonths: null as number | null, expiresAt: null as string | null, daysLeft: null as number | null, expiryLevel: 'unknown' as ExpiryLevel, expiryLabel: 'بدون سرور', planLabel: null as string | null, serversCount: 0, }; } let worst = servers[0]; for (const s of servers.slice(1)) { if (EXPIRY_RANK[s.expiryLevel] < EXPIRY_RANK[worst.expiryLevel]) { worst = s; } else if ( EXPIRY_RANK[s.expiryLevel] === EXPIRY_RANK[worst.expiryLevel] && s.expiresAt && worst.expiresAt && Date.parse(s.expiresAt) < Date.parse(worst.expiresAt) ) { worst = s; } } const names = servers .map((s) => s.serverName) .filter(Boolean) .slice(0, 2) as string[]; const nameHint = names.length ? ` · ${names.join('، ')}` : ''; const countHint = servers.length > 1 ? ` · ${servers.length} سرور${nameHint}` : ''; return { ...worst, expiryLabel: `${worst.expiryLabel}${countHint}`, serversCount: servers.length, }; } private toSummary(account: ServerAccount) { const base = { id: account.id, 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, planLabel: account.planMonths ? this.planLabel(account.planMonths) : null, }; if (account.isDedicated) { return { ...base, daysLeft: null as number | null, expiryLevel: 'dedicated' as ExpiryLevel, expiryLabel: 'اختصاصی', }; } if (!account.expiresAt) { return { ...base, daysLeft: null, expiryLevel: 'unknown' as ExpiryLevel, expiryLabel: 'بدون تاریخ اعتبار', }; } 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 { ...base, daysLeft, expiryLevel, expiryLabel, }; } 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 serverLabel(account: Pick) { return account.serverName?.trim() || `سرور ${account.id.slice(-6)}`; } 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 requireAccount(accountId: string) { const account = await this.prisma.serverAccount.findUnique({ where: { id: accountId }, }); if (!account) throw new NotFoundException('سرور یافت نشد'); return account; } private async requireTenant(tenantId: string) { const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId }, }); if (!tenant) throw new NotFoundException('نسخه یافت نشد'); return tenant; } /** * Kill switch for this sold version's app+admin (physical server stays up). * Bumps appVersion and pushes flag to product /super/settings for immediate effect. */ async setServerAccessBlocked( tenantId: string, blocked: boolean, userId?: string, ) { const before = await this.requireTenant(tenantId); if (Boolean(before.serverAccessBlocked) === blocked) { return { tenant: { id: before.id, serverAccessBlocked: Boolean(before.serverAccessBlocked), serverAccessBlockedAt: before.serverAccessBlockedAt, appVersion: before.appVersion, }, push: { skipped: true, reason: 'unchanged' as const }, }; } const nextVersion = this.bumpAppVersion(before.appVersion); const updated = await this.prisma.tenant.update({ where: { id: tenantId }, data: { serverAccessBlocked: blocked, serverAccessBlockedAt: blocked ? new Date() : null, appVersion: nextVersion, }, }); await this.audit.log({ action: blocked ? 'tenant.server_access.block' : 'tenant.server_access.unblock', entityType: 'tenant', entityId: tenantId, tenantId, userId, before: { serverAccessBlocked: before.serverAccessBlocked, appVersion: before.appVersion, } as Prisma.InputJsonValue, after: { serverAccessBlocked: updated.serverAccessBlocked, appVersion: updated.appVersion, } as Prisma.InputJsonValue, }); this.notify.notifyAdmins( blocked ? `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» قطع شد (ظاهر قطعی سرور).` : `سوپرادمین: دسترسی اپ/ادمین «${updated.nameFa}» وصل شد.`, ); let push: { ok: boolean; stub?: boolean; message?: string; result?: unknown; }; try { const result = await this.settingsProxy.updateSettings( tenantId, { server_access_blocked: blocked }, userId, ); push = { ok: true, stub: Boolean((result as { stub?: boolean })?.stub), message: (result as { message?: string })?.message, result, }; } catch (error: any) { push = { ok: false, message: error?.message || 'قفل در رجیستری ثبت شد ولی push فوری به سرور نسخه ناموفق بود', }; } return { tenant: { id: updated.id, serverAccessBlocked: updated.serverAccessBlocked, serverAccessBlockedAt: updated.serverAccessBlockedAt, appVersion: updated.appVersion, }, push, }; } private bumpAppVersion(current?: string | null): string { const v = String(current || '1').trim() || '1'; if (/^\d+$/.test(v)) { return String(Number(v) + 1); } const semver = v.match(/^(\d+)\.(\d+)\.(\d+)$/); if (semver) { return `${semver[1]}.${semver[2]}.${Number(semver[3]) + 1}`; } return `${Date.now()}`; } }