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>
783 lines
23 KiB
TypeScript
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()}`;
|
|
}
|
|
}
|