Files
api/src/servers/servers.service.ts
T
MahdiandCursor 51e350405a feat: per-tenant server access kill switch
Add Tenant.serverAccessBlocked, toggle API with appVersion bump and settings push, and expose stealth server_reachable in get_user.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-08-11 16:05:04 +03:30

783 lines
23 KiB
TypeScript

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<ExpiryLevel, number> = {
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<ReturnType<ServersService['toSummary']>>,
) {
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<ServerAccount, 'serverName' | 'id'>) {
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()}`;
}
}