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:
@@ -42,7 +42,7 @@ Workspace پیشنهادی: `E:\projeha\Abshode-suite.code-workspace`
|
|||||||
- Auth JWT، CRUD نسخهها (Tenant)، Audit
|
- Auth JWT، CRUD نسخهها (Tenant)، Audit
|
||||||
- مدیران سوپرادمین: نام / نامخانوادگی / موبایل / رمز؛ ورود با موبایل؛ صفحه `/admins`
|
- مدیران سوپرادمین: نام / نامخانوادگی / موبایل / رمز؛ ورود با موبایل؛ صفحه `/admins`
|
||||||
- پیامک هشدار مدیران از API مرکزی اساماس رایگان (`CENTRAL_SMS_*` → `createsms`)؛ رویدادهای نسخه و کیف پول SMS
|
- پیامک هشدار مدیران از API مرکزی اساماس رایگان (`CENTRAL_SMS_*` → `createsms`)؛ رویدادهای نسخه و کیف پول SMS
|
||||||
- پنل سرور: اختصاصی یا مدیریتشده؛ نام/مشخصات/قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیشفرض ۳ روز، روزانه تا تمدید)
|
- پنل سرور: هر نسخه ۰..N سرور (نام/مشخصات جدا)؛ هر کدام اختصاصی یا مدیریتشده؛ قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیشفرض ۳ روز، روزانه تا تمدید)
|
||||||
- پنل افزونه: هر نسخه ۰..N افزونه (`PluginSubscription` + `PluginCharge`)؛ نام اجباری + مشخصات/قیمت/مدت ۱،۳،۶،۱۲ ماه؛ هشدار SMS مثل سرور؛ صفحات `/plugins` و `/plugins/:tenantId`
|
- پنل افزونه: هر نسخه ۰..N افزونه (`PluginSubscription` + `PluginCharge`)؛ نام اجباری + مشخصات/قیمت/مدت ۱،۳،۶،۱۲ ماه؛ هشدار SMS مثل سرور؛ صفحات `/plugins` و `/plugins/:tenantId`
|
||||||
- `GET /get_user` با تطبیق دامنه اصلی + آرایه `domains` + `app_ui_config` برای خانگی
|
- `GET /get_user` با تطبیق دامنه اصلی + آرایه `domains` + `app_ui_config` برای خانگی
|
||||||
- `GET /get_version?domain=` — ورژن همان نسخه فروشی (`Tenant.appVersion`)؛ تنظیم از صفحه مدیریت نسخه؛ جایگزین `nerkh.gold/get_version`
|
- `GET /get_version?domain=` — ورژن همان نسخه فروشی (`Tenant.appVersion`)؛ تنظیم از صفحه مدیریت نسخه؛ جایگزین `nerkh.gold/get_version`
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
-- Allow multiple hosting servers per sold version (tenant).
|
||||||
|
DROP INDEX IF EXISTS "public"."server_accounts_tenant_id_key";
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS "server_accounts_tenant_id_idx" ON "public"."server_accounts"("tenant_id");
|
||||||
@@ -71,7 +71,8 @@ model Tenant {
|
|||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
auditLogs AuditLog[]
|
auditLogs AuditLog[]
|
||||||
smsAccount SmsAccount?
|
smsAccount SmsAccount?
|
||||||
serverAccount ServerAccount?
|
/// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL)
|
||||||
|
serverAccounts ServerAccount[]
|
||||||
pluginSubscriptions PluginSubscription[]
|
pluginSubscriptions PluginSubscription[]
|
||||||
|
|
||||||
@@index([product])
|
@@index([product])
|
||||||
@@ -187,7 +188,7 @@ model AuditLog {
|
|||||||
/// isDedicated=false → we manage charge/renewal/expiry alerts.
|
/// isDedicated=false → we manage charge/renewal/expiry alerts.
|
||||||
model ServerAccount {
|
model ServerAccount {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
tenantId String @unique @map("tenant_id")
|
tenantId String @map("tenant_id")
|
||||||
isDedicated Boolean @default(false) @map("is_dedicated")
|
isDedicated Boolean @default(false) @map("is_dedicated")
|
||||||
serverName String? @map("server_name")
|
serverName String? @map("server_name")
|
||||||
specs String?
|
specs String?
|
||||||
@@ -209,6 +210,7 @@ model ServerAccount {
|
|||||||
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
|
||||||
charges ServerCharge[]
|
charges ServerCharge[]
|
||||||
|
|
||||||
|
@@index([tenantId])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@index([isDedicated])
|
@@index([isDedicated])
|
||||||
@@map("server_accounts")
|
@@map("server_accounts")
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import {
|
import {
|
||||||
Body,
|
Body,
|
||||||
Controller,
|
Controller,
|
||||||
|
Delete,
|
||||||
Get,
|
Get,
|
||||||
Param,
|
Param,
|
||||||
Patch,
|
Patch,
|
||||||
@@ -34,12 +35,22 @@ export class ServersController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Get('tenants/:id')
|
@Get('tenants/:id')
|
||||||
getAccount(@Param('id') id: string) {
|
getTenantServers(@Param('id') id: string) {
|
||||||
return this.serversService.getAccount(id);
|
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')
|
@Patch('tenants/:id')
|
||||||
updateAccount(
|
updateAccountLegacy(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: UpdateServerAccountDto,
|
@Body() dto: UpdateServerAccountDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
@@ -47,12 +58,44 @@ export class ServersController {
|
|||||||
return this.serversService.updateAccount(id, dto, user.id);
|
return this.serversService.updateAccount(id, dto, user.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** @deprecated prefer POST /servers/accounts/:accountId/renew */
|
||||||
@Post('tenants/:id/renew')
|
@Post('tenants/:id/renew')
|
||||||
renew(
|
renewLegacy(
|
||||||
@Param('id') id: string,
|
@Param('id') id: string,
|
||||||
@Body() dto: RenewServerDto,
|
@Body() dto: RenewServerDto,
|
||||||
@CurrentUser() user: { id: string },
|
@CurrentUser() user: { id: string },
|
||||||
) {
|
) {
|
||||||
return this.serversService.renew(id, dto, user.id);
|
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,
|
iranYmd,
|
||||||
} from '../common/iran-time.util';
|
} 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()
|
@Injectable()
|
||||||
export class ServersService implements OnModuleInit, OnModuleDestroy {
|
export class ServersService implements OnModuleInit, OnModuleDestroy {
|
||||||
@@ -35,7 +48,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
onModuleInit() {
|
||||||
// Check once shortly after boot, then every 6 hours
|
|
||||||
setTimeout(() => void this.runExpiryAlerts(), 15_000);
|
setTimeout(() => void this.runExpiryAlerts(), 15_000);
|
||||||
this.alertTimer = setInterval(
|
this.alertTimer = setInterval(
|
||||||
() => void this.runExpiryAlerts(),
|
() => void this.runExpiryAlerts(),
|
||||||
@@ -48,18 +60,21 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async listTenants() {
|
async listTenants() {
|
||||||
await this.ensureAccountsForAllTenants();
|
|
||||||
void this.runExpiryAlerts();
|
void this.runExpiryAlerts();
|
||||||
|
|
||||||
const rows = await this.prisma.tenant.findMany({
|
const rows = await this.prisma.tenant.findMany({
|
||||||
where: { isActive: true },
|
where: { isActive: true },
|
||||||
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
|
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
|
||||||
include: { serverAccount: true },
|
include: {
|
||||||
|
serverAccounts: { orderBy: { createdAt: 'asc' } },
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
return rows.map((tenant) => {
|
return rows.map((tenant) => {
|
||||||
const account = tenant.serverAccount!;
|
const servers = tenant.serverAccounts.map((account) =>
|
||||||
const summary = this.toSummary(account);
|
this.toSummary(account),
|
||||||
|
);
|
||||||
|
const worst = this.pickWorstSummary(servers);
|
||||||
return {
|
return {
|
||||||
id: tenant.id,
|
id: tenant.id,
|
||||||
product: tenant.product,
|
product: tenant.product,
|
||||||
@@ -69,15 +84,35 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
domain: tenant.domain,
|
domain: tenant.domain,
|
||||||
avatarUrl: tenant.avatarUrl,
|
avatarUrl: tenant.avatarUrl,
|
||||||
isActive: tenant.isActive,
|
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) {
|
async getExpiringSoon(limit = 5) {
|
||||||
const rows = await this.listTenants();
|
const rows = await this.listTenants();
|
||||||
return rows
|
const flat = [];
|
||||||
.filter((r) => !r.server.isDedicated && r.server.expiresAt)
|
|
||||||
|
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) => {
|
.sort((a, b) => {
|
||||||
const ae = a.server.expiresAt ? Date.parse(a.server.expiresAt) : Infinity;
|
const ae = a.server.expiresAt ? Date.parse(a.server.expiresAt) : Infinity;
|
||||||
const be = b.server.expiresAt ? Date.parse(b.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);
|
.slice(0, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getAccount(tenantId: string) {
|
async getTenantServers(tenantId: string) {
|
||||||
const tenant = await this.requireTenant(tenantId);
|
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({
|
const charges = await this.prisma.serverCharge.findMany({
|
||||||
where: { serverAccountId: account.id },
|
where: { serverAccountId: account.id },
|
||||||
orderBy: { createdAt: 'desc' },
|
orderBy: { createdAt: 'desc' },
|
||||||
@@ -119,12 +187,48 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateAccount(
|
async createServerAccount(
|
||||||
tenantId: string,
|
tenantId: string,
|
||||||
dto: UpdateServerAccountDto,
|
dto: UpdateServerAccountDto,
|
||||||
userId?: string,
|
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 };
|
const before = { ...account };
|
||||||
|
|
||||||
if (dto.planMonths != null && !PLAN_MONTHS.includes(dto.planMonths as any)) {
|
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 chargedAt = account.chargedAt;
|
||||||
let planMonths = account.planMonths;
|
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) {
|
if (dto.chargedAt !== undefined) {
|
||||||
chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : null;
|
chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : null;
|
||||||
}
|
}
|
||||||
@@ -147,7 +246,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
planMonths = dto.planMonths;
|
planMonths = dto.planMonths;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Recalc expiry when charge date or plan changes (and not dedicated-only clear)
|
|
||||||
if (
|
if (
|
||||||
dto.isDedicated !== true &&
|
dto.isDedicated !== true &&
|
||||||
(dto.chargedAt !== undefined || dto.planMonths !== undefined) &&
|
(dto.chargedAt !== undefined || dto.planMonths !== undefined) &&
|
||||||
@@ -157,10 +255,6 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
expiresAt = this.addMonths(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({
|
const updated = await this.prisma.serverAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -187,13 +281,10 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
? undefined
|
? undefined
|
||||||
: dto.description?.trim() || null,
|
: dto.description?.trim() || null,
|
||||||
alertDaysBefore: dto.alertDaysBefore ?? undefined,
|
alertDaysBefore: dto.alertDaysBefore ?? undefined,
|
||||||
...(dto.isDedicated === true
|
...(dto.isDedicated === true ? { lastAlertDate: null } : {}),
|
||||||
? { lastAlertDate: null }
|
|
||||||
: {}),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
// If we toggled off dedicated and have charge+plan, ensure expiresAt
|
|
||||||
if (
|
if (
|
||||||
updated.isDedicated === false &&
|
updated.isDedicated === false &&
|
||||||
updated.chargedAt &&
|
updated.chargedAt &&
|
||||||
@@ -213,25 +304,62 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
action: 'server.account.update',
|
action: 'server.account.update',
|
||||||
entityType: 'server_account',
|
entityType: 'server_account',
|
||||||
entityId: updated.id,
|
entityId: updated.id,
|
||||||
tenantId,
|
tenantId: updated.tenantId,
|
||||||
userId,
|
userId,
|
||||||
before: this.publicAccount(before) as unknown as Prisma.InputJsonValue,
|
before: this.publicAccount(before) as unknown as Prisma.InputJsonValue,
|
||||||
after: this.publicAccount(updated) 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) {
|
if (before.isDedicated !== updated.isDedicated) {
|
||||||
|
const label = this.serverLabel(updated);
|
||||||
this.notify.notifyAdmins(
|
this.notify.notifyAdmins(
|
||||||
updated.isDedicated
|
updated.isDedicated
|
||||||
? `سوپرادمین: سرور «${tenant.nameFa}» روی حالت اختصاصی قرار گرفت.`
|
? `سوپرادمین: سرور «${tenant.nameFa}» / ${label} روی حالت اختصاصی قرار گرفت.`
|
||||||
: `سوپرادمین: سرور «${tenant.nameFa}» از حالت اختصاصی خارج شد (مدیریت با ما).`,
|
: `سوپرادمین: سرور «${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)) {
|
if (!PLAN_MONTHS.includes(dto.planMonths as any)) {
|
||||||
throw new BadRequestException('مدت شارژ نامعتبر است');
|
throw new BadRequestException('مدت شارژ نامعتبر است');
|
||||||
}
|
}
|
||||||
@@ -239,12 +367,12 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
throw new BadRequestException('قیمت نامعتبر است');
|
throw new BadRequestException('قیمت نامعتبر است');
|
||||||
}
|
}
|
||||||
|
|
||||||
const tenant = await this.requireTenant(tenantId);
|
const account = await this.requireAccount(accountId);
|
||||||
const account = await this.ensureAccount(tenantId);
|
const tenant = await this.requireTenant(account.tenantId);
|
||||||
|
|
||||||
if (account.isDedicated) {
|
if (account.isDedicated) {
|
||||||
throw new BadRequestException(
|
throw new BadRequestException(
|
||||||
'این نسخه سرور اختصاصی است؛ شارژ مدیریتشده لازم نیست. ابتدا سوئیچ اختصاصی را خاموش کنید.',
|
'این سرور اختصاصی است؛ شارژ مدیریتشده لازم نیست. ابتدا سوئیچ اختصاصی را خاموش کنید.',
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -254,7 +382,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
const expiresAt = this.addMonths(chargedAt, dto.planMonths);
|
const expiresAt = this.addMonths(chargedAt, dto.planMonths);
|
||||||
|
|
||||||
const [updated] = await this.prisma.$transaction([
|
await this.prisma.$transaction([
|
||||||
this.prisma.serverAccount.update({
|
this.prisma.serverAccount.update({
|
||||||
where: { id: account.id },
|
where: { id: account.id },
|
||||||
data: {
|
data: {
|
||||||
@@ -282,7 +410,7 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
action: 'server.renew',
|
action: 'server.renew',
|
||||||
entityType: 'server_account',
|
entityType: 'server_account',
|
||||||
entityId: account.id,
|
entityId: account.id,
|
||||||
tenantId,
|
tenantId: tenant.id,
|
||||||
userId,
|
userId,
|
||||||
after: {
|
after: {
|
||||||
chargedAt: chargedAt.toISOString(),
|
chargedAt: chargedAt.toISOString(),
|
||||||
@@ -293,11 +421,25 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
} as unknown as Prisma.InputJsonValue,
|
} as unknown as Prisma.InputJsonValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const label = this.serverLabel(account);
|
||||||
this.notify.notifyAdmins(
|
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() {
|
async runExpiryAlerts() {
|
||||||
@@ -337,8 +479,9 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
? 'امروز منقضی میشود'
|
? 'امروز منقضی میشود'
|
||||||
: `${daysLeft} روز تا پایان اعتبار`;
|
: `${daysLeft} روز تا پایان اعتبار`;
|
||||||
|
|
||||||
|
const label = this.serverLabel(account);
|
||||||
this.notify.notifyAdmins(
|
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({
|
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) {
|
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) {
|
if (account.isDedicated) {
|
||||||
return {
|
return {
|
||||||
isDedicated: true,
|
...base,
|
||||||
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,
|
daysLeft: null as number | null,
|
||||||
expiryLevel: 'dedicated' as ExpiryLevel,
|
expiryLevel: 'dedicated' as ExpiryLevel,
|
||||||
expiryLabel: 'اختصاصی',
|
expiryLabel: 'اختصاصی',
|
||||||
planLabel: account.planMonths
|
|
||||||
? this.planLabel(account.planMonths)
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!account.expiresAt) {
|
if (!account.expiresAt) {
|
||||||
return {
|
return {
|
||||||
isDedicated: false,
|
...base,
|
||||||
serverName: account.serverName,
|
|
||||||
specs: account.specs,
|
|
||||||
priceRials: account.priceRials,
|
|
||||||
chargedAt: account.chargedAt?.toISOString() ?? null,
|
|
||||||
planMonths: account.planMonths,
|
|
||||||
expiresAt: null,
|
|
||||||
daysLeft: null,
|
daysLeft: null,
|
||||||
expiryLevel: 'unknown' as ExpiryLevel,
|
expiryLevel: 'unknown' as ExpiryLevel,
|
||||||
expiryLabel: 'بدون تاریخ اعتبار',
|
expiryLabel: 'بدون تاریخ اعتبار',
|
||||||
planLabel: account.planMonths
|
|
||||||
? this.planLabel(account.planMonths)
|
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -411,17 +600,10 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
isDedicated: false,
|
...base,
|
||||||
serverName: account.serverName,
|
|
||||||
specs: account.specs,
|
|
||||||
priceRials: account.priceRials,
|
|
||||||
chargedAt: account.chargedAt?.toISOString() ?? null,
|
|
||||||
planMonths: account.planMonths,
|
|
||||||
expiresAt: account.expiresAt.toISOString(),
|
|
||||||
daysLeft,
|
daysLeft,
|
||||||
expiryLevel,
|
expiryLevel,
|
||||||
expiryLabel,
|
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) {
|
private planLabel(months: number) {
|
||||||
switch (months) {
|
switch (months) {
|
||||||
case 1:
|
case 1:
|
||||||
@@ -462,37 +648,18 @@ export class ServersService implements OnModuleInit, OnModuleDestroy {
|
|||||||
const d = new Date(from.getTime());
|
const d = new Date(from.getTime());
|
||||||
const day = d.getDate();
|
const day = d.getDate();
|
||||||
d.setMonth(d.getMonth() + months);
|
d.setMonth(d.getMonth() + months);
|
||||||
// Handle month-end overflow (e.g. Jan 31 + 1 month)
|
|
||||||
if (d.getDate() < day) {
|
if (d.getDate() < day) {
|
||||||
d.setDate(0);
|
d.setDate(0);
|
||||||
}
|
}
|
||||||
return d;
|
return d;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async ensureAccountsForAllTenants() {
|
private async requireAccount(accountId: string) {
|
||||||
const tenants = await this.prisma.tenant.findMany({
|
const account = await this.prisma.serverAccount.findUnique({
|
||||||
where: { serverAccount: null },
|
where: { id: accountId },
|
||||||
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,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
if (!account) throw new NotFoundException('سرور یافت نشد');
|
||||||
|
return account;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async requireTenant(tenantId: string) {
|
private async requireTenant(tenantId: string) {
|
||||||
|
|||||||
Reference in New Issue
Block a user