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:
2026-08-06 19:23:37 +03:30
co-authored by Cursor
parent c6d90ea262
commit f61d10e07b
5 changed files with 316 additions and 100 deletions
+1 -1
View File
@@ -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`
@@ -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");
+4 -2
View File
@@ -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")
+47 -4
View File
@@ -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);
}
}
+256 -89
View File
@@ -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 toSummary(account: ServerAccount) {
if (account.isDedicated) {
private pickWorstSummary(
servers: Array<ReturnType<ServersService['toSummary']>>,
) {
if (!servers.length) {
return {
isDedicated: true,
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,
daysLeft: null as number | null,
expiryLevel: 'dedicated' as ExpiryLevel,
expiryLabel: 'اختصاصی',
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 {
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) {