From f61d10e07ba1452e62cee19200984fabc6452431 Mon Sep 17 00:00:00 2001 From: Mahdi Date: Thu, 6 Aug 2026 19:23:37 +0330 Subject: [PATCH] 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 --- AGENTS.md | 2 +- .../migration.sql | 4 + prisma/schema.prisma | 6 +- src/servers/servers.controller.ts | 51 ++- src/servers/servers.service.ts | 353 +++++++++++++----- 5 files changed, 316 insertions(+), 100 deletions(-) create mode 100644 prisma/migrations/20260806190000_multi_server_accounts/migration.sql diff --git a/AGENTS.md b/AGENTS.md index 0d32d78..7170ff8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -42,7 +42,7 @@ Workspace پیشنهادی: `E:\projeha\Abshode-suite.code-workspace` - Auth JWT، CRUD نسخه‌ها (Tenant)، Audit - مدیران سوپرادمین: نام / نام‌خانوادگی / موبایل / رمز؛ ورود با موبایل؛ صفحه `/admins` - پیامک هشدار مدیران از API مرکزی اس‌ام‌اس رایگان (`CENTRAL_SMS_*` → `createsms`)؛ رویدادهای نسخه و کیف پول SMS -- پنل سرور: اختصاصی یا مدیریت‌شده؛ نام/مشخصات/قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیش‌فرض ۳ روز، روزانه تا تمدید) +- پنل سرور: هر نسخه ۰..N سرور (نام/مشخصات جدا)؛ هر کدام اختصاصی یا مدیریت‌شده؛ قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیش‌فرض ۳ روز، روزانه تا تمدید) - پنل افزونه: هر نسخه ۰..N افزونه (`PluginSubscription` + `PluginCharge`)؛ نام اجباری + مشخصات/قیمت/مدت ۱،۳،۶،۱۲ ماه؛ هشدار SMS مثل سرور؛ صفحات `/plugins` و `/plugins/:tenantId` - `GET /get_user` با تطبیق دامنه اصلی + آرایه `domains` + `app_ui_config` برای خانگی - `GET /get_version?domain=` — ورژن همان نسخه فروشی (`Tenant.appVersion`)؛ تنظیم از صفحه مدیریت نسخه؛ جایگزین `nerkh.gold/get_version` diff --git a/prisma/migrations/20260806190000_multi_server_accounts/migration.sql b/prisma/migrations/20260806190000_multi_server_accounts/migration.sql new file mode 100644 index 0000000..8aca3fd --- /dev/null +++ b/prisma/migrations/20260806190000_multi_server_accounts/migration.sql @@ -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"); diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 27e3560..c279e2c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -71,7 +71,8 @@ model Tenant { updatedAt DateTime @updatedAt @map("updated_at") auditLogs AuditLog[] smsAccount SmsAccount? - serverAccount ServerAccount? + /// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL) + serverAccounts ServerAccount[] pluginSubscriptions PluginSubscription[] @@index([product]) @@ -187,7 +188,7 @@ model AuditLog { /// isDedicated=false → we manage charge/renewal/expiry alerts. model ServerAccount { id String @id @default(cuid()) - tenantId String @unique @map("tenant_id") + tenantId String @map("tenant_id") isDedicated Boolean @default(false) @map("is_dedicated") serverName String? @map("server_name") specs String? @@ -209,6 +210,7 @@ model ServerAccount { tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) charges ServerCharge[] + @@index([tenantId]) @@index([expiresAt]) @@index([isDedicated]) @@map("server_accounts") diff --git a/src/servers/servers.controller.ts b/src/servers/servers.controller.ts index fd258c1..c2b9469 100644 --- a/src/servers/servers.controller.ts +++ b/src/servers/servers.controller.ts @@ -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); + } } diff --git a/src/servers/servers.service.ts b/src/servers/servers.service.ts index 3a9acce..8b1b8af 100644 --- a/src/servers/servers.service.ts +++ b/src/servers/servers.service.ts @@ -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 = { + 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>, + ) { + 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) { + 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) {