Allow multiple hosting servers per sold version.
Drop unique tenant_id on server_accounts and add create/update/delete/renew APIs per server account, with list/expiring using the worst status. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
@@ -34,12 +35,22 @@ export class ServersController {
|
||||
}
|
||||
|
||||
@Get('tenants/:id')
|
||||
getAccount(@Param('id') id: string) {
|
||||
return this.serversService.getAccount(id);
|
||||
getTenantServers(@Param('id') id: string) {
|
||||
return this.serversService.getTenantServers(id);
|
||||
}
|
||||
|
||||
@Post('tenants/:id/servers')
|
||||
createServer(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateServerAccountDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.serversService.createServerAccount(id, dto, user.id);
|
||||
}
|
||||
|
||||
/** @deprecated prefer PATCH /servers/accounts/:accountId */
|
||||
@Patch('tenants/:id')
|
||||
updateAccount(
|
||||
updateAccountLegacy(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: UpdateServerAccountDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
@@ -47,12 +58,44 @@ export class ServersController {
|
||||
return this.serversService.updateAccount(id, dto, user.id);
|
||||
}
|
||||
|
||||
/** @deprecated prefer POST /servers/accounts/:accountId/renew */
|
||||
@Post('tenants/:id/renew')
|
||||
renew(
|
||||
renewLegacy(
|
||||
@Param('id') id: string,
|
||||
@Body() dto: RenewServerDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.serversService.renew(id, dto, user.id);
|
||||
}
|
||||
|
||||
@Get('accounts/:accountId')
|
||||
getServerAccount(@Param('accountId') accountId: string) {
|
||||
return this.serversService.getServerAccount(accountId);
|
||||
}
|
||||
|
||||
@Patch('accounts/:accountId')
|
||||
updateServerAccount(
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() dto: UpdateServerAccountDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.serversService.updateServerAccount(accountId, dto, user.id);
|
||||
}
|
||||
|
||||
@Delete('accounts/:accountId')
|
||||
deleteServerAccount(
|
||||
@Param('accountId') accountId: string,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.serversService.deleteServerAccount(accountId, user.id);
|
||||
}
|
||||
|
||||
@Post('accounts/:accountId/renew')
|
||||
renewAccount(
|
||||
@Param('accountId') accountId: string,
|
||||
@Body() dto: RenewServerDto,
|
||||
@CurrentUser() user: { id: string },
|
||||
) {
|
||||
return this.serversService.renewAccount(accountId, dto, user.id);
|
||||
}
|
||||
}
|
||||
|
||||
+260
-93
@@ -21,7 +21,20 @@ import {
|
||||
iranYmd,
|
||||
} from '../common/iran-time.util';
|
||||
|
||||
export type ExpiryLevel = 'dedicated' | 'healthy' | 'warning' | 'critical' | 'unknown';
|
||||
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 {
|
||||
@@ -35,7 +48,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
// Check once shortly after boot, then every 6 hours
|
||||
setTimeout(() => void this.runExpiryAlerts(), 15_000);
|
||||
this.alertTimer = setInterval(
|
||||
() => void this.runExpiryAlerts(),
|
||||
@@ -48,18 +60,21 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
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 },
|
||||
include: {
|
||||
serverAccounts: { orderBy: { createdAt: 'asc' } },
|
||||
},
|
||||
});
|
||||
|
||||
return rows.map((tenant) => {
|
||||
const account = tenant.serverAccount!;
|
||||
const summary = this.toSummary(account);
|
||||
const servers = tenant.serverAccounts.map((account) =>
|
||||
this.toSummary(account),
|
||||
);
|
||||
const worst = this.pickWorstSummary(servers);
|
||||
return {
|
||||
id: tenant.id,
|
||||
product: tenant.product,
|
||||
@@ -69,15 +84,35 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
domain: tenant.domain,
|
||||
avatarUrl: tenant.avatarUrl,
|
||||
isActive: tenant.isActive,
|
||||
server: summary,
|
||||
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();
|
||||
return rows
|
||||
.filter((r) => !r.server.isDedicated && r.server.expiresAt)
|
||||
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;
|
||||
@@ -90,9 +125,42 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
.slice(0, limit);
|
||||
}
|
||||
|
||||
async getAccount(tenantId: string) {
|
||||
async getTenantServers(tenantId: string) {
|
||||
const tenant = await this.requireTenant(tenantId);
|
||||
const account = await this.ensureAccount(tenant.id);
|
||||
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,
|
||||
},
|
||||
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' },
|
||||
@@ -119,12 +187,48 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
async updateAccount(
|
||||
async createServerAccount(
|
||||
tenantId: string,
|
||||
dto: UpdateServerAccountDto,
|
||||
userId?: string,
|
||||
) {
|
||||
const account = await this.ensureAccount(tenantId);
|
||||
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)) {
|
||||
@@ -135,11 +239,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
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;
|
||||
}
|
||||
@@ -147,7 +246,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
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) &&
|
||||
@@ -157,10 +255,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
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: {
|
||||
@@ -187,13 +281,10 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
? undefined
|
||||
: dto.description?.trim() || null,
|
||||
alertDaysBefore: dto.alertDaysBefore ?? undefined,
|
||||
...(dto.isDedicated === true
|
||||
? { lastAlertDate: null }
|
||||
: {}),
|
||||
...(dto.isDedicated === true ? { lastAlertDate: null } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
// If we toggled off dedicated and have charge+plan, ensure expiresAt
|
||||
if (
|
||||
updated.isDedicated === false &&
|
||||
updated.chargedAt &&
|
||||
@@ -213,25 +304,62 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
action: 'server.account.update',
|
||||
entityType: 'server_account',
|
||||
entityId: updated.id,
|
||||
tenantId,
|
||||
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(tenantId);
|
||||
const tenant = await this.requireTenant(updated.tenantId);
|
||||
if (before.isDedicated !== updated.isDedicated) {
|
||||
const label = this.serverLabel(updated);
|
||||
this.notify.notifyAdmins(
|
||||
updated.isDedicated
|
||||
? `سوپرادمین: سرور «${tenant.nameFa}» روی حالت اختصاصی قرار گرفت.`
|
||||
: `سوپرادمین: سرور «${tenant.nameFa}» از حالت اختصاصی خارج شد (مدیریت با ما).`,
|
||||
? `سوپرادمین: سرور «${tenant.nameFa}» / ${label} روی حالت اختصاصی قرار گرفت.`
|
||||
: `سوپرادمین: سرور «${tenant.nameFa}» / ${label} از حالت اختصاصی خارج شد (مدیریت با ما).`,
|
||||
);
|
||||
}
|
||||
|
||||
return this.getAccount(tenantId);
|
||||
return this.getServerAccount(updated.id);
|
||||
}
|
||||
|
||||
async renew(tenantId: string, dto: RenewServerDto, userId?: string) {
|
||||
/** 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('مدت شارژ نامعتبر است');
|
||||
}
|
||||
@@ -239,12 +367,12 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
throw new BadRequestException('قیمت نامعتبر است');
|
||||
}
|
||||
|
||||
const tenant = await this.requireTenant(tenantId);
|
||||
const account = await this.ensureAccount(tenantId);
|
||||
const account = await this.requireAccount(accountId);
|
||||
const tenant = await this.requireTenant(account.tenantId);
|
||||
|
||||
if (account.isDedicated) {
|
||||
throw new BadRequestException(
|
||||
'این نسخه سرور اختصاصی است؛ شارژ مدیریتشده لازم نیست. ابتدا سوئیچ اختصاصی را خاموش کنید.',
|
||||
'این سرور اختصاصی است؛ شارژ مدیریتشده لازم نیست. ابتدا سوئیچ اختصاصی را خاموش کنید.',
|
||||
);
|
||||
}
|
||||
|
||||
@@ -254,7 +382,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
const expiresAt = this.addMonths(chargedAt, dto.planMonths);
|
||||
|
||||
const [updated] = await this.prisma.$transaction([
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.serverAccount.update({
|
||||
where: { id: account.id },
|
||||
data: {
|
||||
@@ -282,7 +410,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
action: 'server.renew',
|
||||
entityType: 'server_account',
|
||||
entityId: account.id,
|
||||
tenantId,
|
||||
tenantId: tenant.id,
|
||||
userId,
|
||||
after: {
|
||||
chargedAt: chargedAt.toISOString(),
|
||||
@@ -293,11 +421,25 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
} as unknown as Prisma.InputJsonValue,
|
||||
});
|
||||
|
||||
const label = this.serverLabel(account);
|
||||
this.notify.notifyAdmins(
|
||||
`سوپرادمین: سرور «${tenant.nameFa}» شارژ/تمدید شد (${this.planLabel(dto.planMonths)}، تا ${formatIranDate(expiresAt)}).`,
|
||||
`سوپرادمین: سرور «${tenant.nameFa}» / ${label} شارژ/تمدید شد (${this.planLabel(dto.planMonths)}، تا ${formatIranDate(expiresAt)}).`,
|
||||
);
|
||||
|
||||
return this.getAccount(tenantId);
|
||||
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() {
|
||||
@@ -337,8 +479,9 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
? 'امروز منقضی میشود'
|
||||
: `${daysLeft} روز تا پایان اعتبار`;
|
||||
|
||||
const label = this.serverLabel(account);
|
||||
this.notify.notifyAdmins(
|
||||
`هشدار سرور: «${account.tenant.nameFa}» (${account.tenant.domain}) — ${when} (انقضا: ${formatIranDate(account.expiresAt)}). لطفاً شارژ/تمدید کنید.`,
|
||||
`هشدار سرور: «${account.tenant.nameFa}» / ${label} (${account.tenant.domain}) — ${when} (انقضا: ${formatIranDate(account.expiresAt)}). لطفاً شارژ/تمدید کنید.`,
|
||||
);
|
||||
|
||||
await this.prisma.serverAccount.update({
|
||||
@@ -356,40 +499,86 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
}
|
||||
|
||||
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 {
|
||||
isDedicated: true,
|
||||
serverName: account.serverName,
|
||||
specs: account.specs,
|
||||
priceRials: account.priceRials,
|
||||
chargedAt: account.chargedAt?.toISOString() ?? null,
|
||||
planMonths: account.planMonths,
|
||||
expiresAt: account.expiresAt?.toISOString() ?? null,
|
||||
...base,
|
||||
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,
|
||||
...base,
|
||||
daysLeft: null,
|
||||
expiryLevel: 'unknown' as ExpiryLevel,
|
||||
expiryLabel: 'بدون تاریخ اعتبار',
|
||||
planLabel: account.planMonths
|
||||
? this.planLabel(account.planMonths)
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -411,17 +600,10 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
}
|
||||
|
||||
return {
|
||||
isDedicated: false,
|
||||
serverName: account.serverName,
|
||||
specs: account.specs,
|
||||
priceRials: account.priceRials,
|
||||
chargedAt: account.chargedAt?.toISOString() ?? null,
|
||||
planMonths: account.planMonths,
|
||||
expiresAt: account.expiresAt.toISOString(),
|
||||
...base,
|
||||
daysLeft,
|
||||
expiryLevel,
|
||||
expiryLabel,
|
||||
planLabel: account.planMonths ? this.planLabel(account.planMonths) : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -443,6 +625,10 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
};
|
||||
}
|
||||
|
||||
private serverLabel(account: Pick<ServerAccount, 'serverName' | 'id'>) {
|
||||
return account.serverName?.trim() || `سرور ${account.id.slice(-6)}`;
|
||||
}
|
||||
|
||||
private planLabel(months: number) {
|
||||
switch (months) {
|
||||
case 1:
|
||||
@@ -462,37 +648,18 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||
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 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) {
|
||||
|
||||
Reference in New Issue
Block a user