Add support subscription panel API with optional free periods.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-08-08 11:20:25 +03:30
co-authored by Cursor
parent f61d10e07b
commit db66a44527
8 changed files with 916 additions and 0 deletions
+2
View File
@@ -43,6 +43,8 @@ Workspace پیشنهادی: `E:\projeha\Abshode-suite.code-workspace`
- مدیران سوپرادمین: نام / نام‌خانوادگی / موبایل / رمز؛ ورود با موبایل؛ صفحه `/admins` - مدیران سوپرادمین: نام / نام‌خانوادگی / موبایل / رمز؛ ورود با موبایل؛ صفحه `/admins`
- پیامک هشدار مدیران از API مرکزی اس‌ام‌اس رایگان (`CENTRAL_SMS_*` → `createsms`)؛ رویدادهای نسخه و کیف پول SMS - پیامک هشدار مدیران از API مرکزی اس‌ام‌اس رایگان (`CENTRAL_SMS_*` → `createsms`)؛ رویدادهای نسخه و کیف پول SMS
- پنل سرور: هر نسخه ۰..N سرور (نام/مشخصات جدا)؛ هر کدام اختصاصی یا مدیریت‌شده؛ قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیش‌فرض ۳ روز، روزانه تا تمدید) - پنل سرور: هر نسخه ۰..N سرور (نام/مشخصات جدا)؛ هر کدام اختصاصی یا مدیریت‌شده؛ قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیش‌فرض ۳ روز، روزانه تا تمدید)
- پنل پشتیبانی: هر نسخه ۰..N اشتراک پشتیبانی؛ مدت ۱|۳|۶|۱۲ ماه؛ امکان دوره رایگان (isFree، قیمت ۰)؛ هشدار انقضا مثل افزونه
- پنل افزونه: هر نسخه ۰..N افزونه؛ قیمت/مدت/هشدار انقضا
- پنل افزونه: هر نسخه ۰..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,45 @@
-- Support / maintenance subscriptions (0..N per tenant), with optional free periods.
CREATE TABLE "public"."support_subscriptions" (
"id" TEXT NOT NULL,
"tenant_id" TEXT NOT NULL,
"name" TEXT NOT NULL,
"specs" TEXT,
"price_rials" INTEGER,
"is_free" BOOLEAN NOT NULL DEFAULT false,
"charged_at" TIMESTAMP(3),
"plan_months" INTEGER,
"expires_at" TIMESTAMP(3),
"description" TEXT,
"alert_days_before" INTEGER NOT NULL DEFAULT 3,
"last_alert_date" TIMESTAMP(3),
"is_active" BOOLEAN NOT NULL DEFAULT true,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updated_at" TIMESTAMP(3) NOT NULL,
CONSTRAINT "support_subscriptions_pkey" PRIMARY KEY ("id")
);
CREATE TABLE "public"."support_charges" (
"id" TEXT NOT NULL,
"support_id" TEXT NOT NULL,
"charged_at" TIMESTAMP(3) NOT NULL,
"plan_months" INTEGER NOT NULL,
"price_rials" INTEGER NOT NULL,
"is_free" BOOLEAN NOT NULL DEFAULT false,
"expires_at" TIMESTAMP(3) NOT NULL,
"note" TEXT,
"created_by_id" TEXT,
"created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "support_charges_pkey" PRIMARY KEY ("id")
);
CREATE INDEX "support_subscriptions_tenant_id_idx" ON "public"."support_subscriptions"("tenant_id");
CREATE INDEX "support_subscriptions_expires_at_idx" ON "public"."support_subscriptions"("expires_at");
CREATE INDEX "support_subscriptions_is_active_idx" ON "public"."support_subscriptions"("is_active");
CREATE INDEX "support_subscriptions_is_free_idx" ON "public"."support_subscriptions"("is_free");
CREATE INDEX "support_charges_support_id_idx" ON "public"."support_charges"("support_id");
CREATE INDEX "support_charges_created_at_idx" ON "public"."support_charges"("created_at");
ALTER TABLE "public"."support_subscriptions" ADD CONSTRAINT "support_subscriptions_tenant_id_fkey" FOREIGN KEY ("tenant_id") REFERENCES "public"."tenants"("id") ON DELETE CASCADE ON UPDATE CASCADE;
ALTER TABLE "public"."support_charges" ADD CONSTRAINT "support_charges_support_id_fkey" FOREIGN KEY ("support_id") REFERENCES "public"."support_subscriptions"("id") ON DELETE CASCADE ON UPDATE CASCADE;
+51
View File
@@ -74,6 +74,7 @@ model Tenant {
/// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL) /// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL)
serverAccounts ServerAccount[] serverAccounts ServerAccount[]
pluginSubscriptions PluginSubscription[] pluginSubscriptions PluginSubscription[]
supportSubscriptions SupportSubscription[]
@@index([product]) @@index([product])
@@index([isActive]) @@index([isActive])
@@ -279,3 +280,53 @@ model PluginCharge {
@@index([createdAt]) @@index([createdAt])
@@map("plugin_charges") @@map("plugin_charges")
} }
/// Support/maintenance subscription for a sold version (0..N per tenant).
/// Can be free (isFree) for 1|3|6|12 months gift periods.
model SupportSubscription {
id String @id @default(cuid())
tenantId String @map("tenant_id")
name String
specs String?
priceRials Int? @map("price_rials")
/// When true, current period is complimentary (price should be 0)
isFree Boolean @default(false) @map("is_free")
chargedAt DateTime? @map("charged_at")
/// Plan length in months: 1 | 3 | 6 | 12
planMonths Int? @map("plan_months")
expiresAt DateTime? @map("expires_at")
description String?
alertDaysBefore Int @default(3) @map("alert_days_before")
lastAlertDate DateTime? @map("last_alert_date")
isActive Boolean @default(true) @map("is_active")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade)
charges SupportCharge[]
@@index([tenantId])
@@index([expiresAt])
@@index([isActive])
@@index([isFree])
@@map("support_subscriptions")
}
model SupportCharge {
id String @id @default(cuid())
supportId String @map("support_id")
chargedAt DateTime @map("charged_at")
planMonths Int @map("plan_months")
priceRials Int @map("price_rials")
isFree Boolean @default(false) @map("is_free")
expiresAt DateTime @map("expires_at")
note String?
createdById String? @map("created_by_id")
createdAt DateTime @default(now()) @map("created_at")
support SupportSubscription @relation(fields: [supportId], references: [id], onDelete: Cascade)
@@index([supportId])
@@index([createdAt])
@@map("support_charges")
}
+2
View File
@@ -10,6 +10,7 @@ import { AuditModule } from './audit/audit.module';
import { SmsModule } from './sms/sms.module'; import { SmsModule } from './sms/sms.module';
import { ServersModule } from './servers/servers.module'; import { ServersModule } from './servers/servers.module';
import { PluginsModule } from './plugins/plugins.module'; import { PluginsModule } from './plugins/plugins.module';
import { SupportModule } from './support/support.module';
import { NotificationsModule } from './notifications/notifications.module'; import { NotificationsModule } from './notifications/notifications.module';
import { AdminsModule } from './admins/admins.module'; import { AdminsModule } from './admins/admins.module';
import { HealthController } from './health.controller'; import { HealthController } from './health.controller';
@@ -28,6 +29,7 @@ import { HealthController } from './health.controller';
SmsModule, SmsModule,
ServersModule, ServersModule,
PluginsModule, PluginsModule,
SupportModule,
AdminsModule, AdminsModule,
], ],
controllers: [HealthController], controllers: [HealthController],
+133
View File
@@ -0,0 +1,133 @@
import {
IsBoolean,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export const SUPPORT_PLAN_MONTHS = [1, 3, 6, 12] as const;
export class CreateSupportDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
specs?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@IsOptional()
@IsBoolean()
isFree?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
priceRials?: number;
@IsOptional()
@IsISO8601()
chargedAt?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@IsIn(SUPPORT_PLAN_MONTHS as unknown as number[])
planMonths?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(30)
alertDaysBefore?: number;
}
export class UpdateSupportDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
specs?: string | null;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsBoolean()
isFree?: boolean;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
priceRials?: number | null;
@IsOptional()
@IsISO8601()
chargedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@IsIn(SUPPORT_PLAN_MONTHS as unknown as number[])
planMonths?: number | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(30)
alertDaysBefore?: number;
@IsOptional()
@IsBoolean()
isActive?: boolean;
}
export class RenewSupportDto {
@IsOptional()
@IsISO8601()
chargedAt?: string;
@Type(() => Number)
@IsInt()
@IsIn(SUPPORT_PLAN_MONTHS as unknown as number[])
planMonths!: number;
@IsOptional()
@IsBoolean()
isFree?: boolean;
@Type(() => Number)
@IsInt()
@Min(0)
priceRials!: number;
@IsOptional()
@IsString()
@MaxLength(500)
note?: string;
}
+77
View File
@@ -0,0 +1,77 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
UseGuards,
} from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { CurrentUser } from '../auth/current-user.decorator';
import {
CreateSupportDto,
RenewSupportDto,
UpdateSupportDto,
} from './dto/support.dto';
import { SupportService } from './support.service';
@Controller('support')
@UseGuards(JwtAuthGuard)
export class SupportController {
constructor(private readonly supportService: SupportService) {}
@Get('tenants')
listTenants() {
return this.supportService.listTenants();
}
@Get('expiring')
expiring(@Query('limit') limit?: string) {
return this.supportService.getExpiringSoon(Number(limit) || 5);
}
@Get('tenants/:tenantId')
getTenantSupports(@Param('tenantId') tenantId: string) {
return this.supportService.getTenantSupports(tenantId);
}
@Post('tenants/:tenantId')
create(
@Param('tenantId') tenantId: string,
@Body() dto: CreateSupportDto,
@CurrentUser() user: { id: string },
) {
return this.supportService.create(tenantId, dto, user.id);
}
@Get('items/:id')
getOne(@Param('id') id: string) {
return this.supportService.getSupport(id);
}
@Patch('items/:id')
update(
@Param('id') id: string,
@Body() dto: UpdateSupportDto,
@CurrentUser() user: { id: string },
) {
return this.supportService.update(id, dto, user.id);
}
@Post('items/:id/renew')
renew(
@Param('id') id: string,
@Body() dto: RenewSupportDto,
@CurrentUser() user: { id: string },
) {
return this.supportService.renew(id, dto, user.id);
}
@Delete('items/:id')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.supportService.remove(id, user.id);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { SupportController } from './support.controller';
import { SupportService } from './support.service';
@Module({
imports: [NotificationsModule],
controllers: [SupportController],
providers: [SupportService],
exports: [SupportService],
})
export class SupportModule {}
+594
View File
@@ -0,0 +1,594 @@
import {
BadRequestException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import { SupportSubscription, Prisma } from '@prisma/client';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AdminNotifyService } from '../notifications/admin-notify.service';
import {
formatIranDate,
iranDaysUntil,
iranYmd,
} from '../common/iran-time.util';
import {
CreateSupportDto,
SUPPORT_PLAN_MONTHS,
RenewSupportDto,
UpdateSupportDto,
} from './dto/support.dto';
export type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none';
@Injectable()
export class SupportService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(SupportService.name);
private alertTimer: NodeJS.Timeout | null = null;
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly notify: AdminNotifyService,
) {}
onModuleInit() {
setTimeout(() => void this.runExpiryAlerts(), 20_000);
this.alertTimer = setInterval(
() => void this.runExpiryAlerts(),
6 * 60 * 60 * 1000,
);
}
onModuleDestroy() {
if (this.alertTimer) clearInterval(this.alertTimer);
}
async listTenants() {
void this.runExpiryAlerts();
const rows = await this.prisma.tenant.findMany({
where: { isActive: true },
orderBy: [{ product: 'asc' }, { nameFa: 'asc' }],
include: {
supportSubscriptions: {
where: { isActive: true },
orderBy: { name: 'asc' },
},
},
});
return rows.map((tenant) => {
const supports = tenant.supportSubscriptions.map((s) => this.toSummary(s));
const worst = this.worstLevel(supports.map((s) => s.expiryLevel));
return {
id: tenant.id,
product: tenant.product,
slug: tenant.slug,
nameFa: tenant.nameFa,
nameEn: tenant.nameEn,
domain: tenant.domain,
avatarUrl: tenant.avatarUrl,
supportCount: supports.length,
supports,
summary: {
supportCount: supports.length,
expiryLevel: worst,
expiryLabel:
supports.length === 0
? 'بدون پشتیبانی'
: worst === 'critical'
? 'پشتیبانی نزدیک انقضا / منقضی'
: worst === 'warning'
? 'پشتیبانی در بازه هشدار'
: worst === 'unknown'
? 'پشتیبانی بدون تاریخ'
: `${supports.length} پشتیبانی فعال`,
},
};
});
}
async getExpiringSoon(limit = 5) {
const supports = await this.prisma.supportSubscription.findMany({
where: {
isActive: true,
expiresAt: { not: null },
tenant: { isActive: true },
},
include: {
tenant: {
select: {
id: true,
product: true,
nameFa: true,
domain: true,
slug: true,
},
},
},
});
return supports
.map((s) => ({
...this.toSummary(s),
id: s.id,
tenantId: s.tenantId,
tenant: s.tenant,
}))
.filter((s) => {
const level = s.expiryLevel;
return level === 'critical' || level === 'warning' || level === 'unknown';
})
.sort((a, b) => {
const ae = a.expiresAt ? Date.parse(a.expiresAt) : Infinity;
const be = b.expiresAt ? Date.parse(b.expiresAt) : Infinity;
return ae - be;
})
.slice(0, limit);
}
async getTenantSupports(tenantId: string) {
const tenant = await this.requireTenant(tenantId);
const supports = await this.prisma.supportSubscription.findMany({
where: { tenantId },
orderBy: [{ isActive: 'desc' }, { name: 'asc' }],
include: {
charges: { orderBy: { createdAt: 'desc' }, take: 10 },
},
});
return {
tenant: {
id: tenant.id,
product: tenant.product,
slug: tenant.slug,
nameFa: tenant.nameFa,
nameEn: tenant.nameEn,
domain: tenant.domain,
avatarUrl: tenant.avatarUrl,
},
supports: supports.map((s) => ({
...this.publicSupport(s),
summary: this.toSummary(s),
charges: s.charges,
})),
planOptions: SUPPORT_PLAN_MONTHS.map((m) => ({
months: m,
label: this.planLabel(m),
})),
};
}
async getSupport(supportId: string) {
const support = await this.prisma.supportSubscription.findUnique({
where: { id: supportId },
include: {
tenant: true,
charges: { orderBy: { createdAt: 'desc' }, take: 20 },
},
});
if (!support) throw new NotFoundException('پشتیبانی یافت نشد');
return {
tenant: {
id: support.tenant.id,
product: support.tenant.product,
slug: support.tenant.slug,
nameFa: support.tenant.nameFa,
domain: support.tenant.domain,
},
support: this.publicSupport(support),
summary: this.toSummary(support),
charges: support.charges,
planOptions: SUPPORT_PLAN_MONTHS.map((m) => ({
months: m,
label: this.planLabel(m),
})),
};
}
async create(tenantId: string, dto: CreateSupportDto, userId?: string) {
await this.requireTenant(tenantId);
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام پشتیبانی لازم است');
const isFree = dto.isFree === true;
const priceRials = isFree ? 0 : (dto.priceRials ?? null);
let chargedAt: Date | null = dto.chargedAt ? new Date(dto.chargedAt) : null;
let planMonths = dto.planMonths ?? null;
let expiresAt: Date | null = null;
if (chargedAt && planMonths) {
if (!SUPPORT_PLAN_MONTHS.includes(planMonths as any)) {
throw new BadRequestException('مدت اشتراک نامعتبر است');
}
expiresAt = this.addMonths(chargedAt, planMonths);
}
const support = await this.prisma.supportSubscription.create({
data: {
tenantId,
name,
specs: dto.specs?.trim() || null,
description: dto.description?.trim() || null,
isFree,
priceRials,
chargedAt,
planMonths,
expiresAt,
alertDaysBefore: dto.alertDaysBefore ?? 3,
},
});
if (chargedAt && planMonths && expiresAt) {
await this.prisma.supportCharge.create({
data: {
supportId: support.id,
chargedAt,
planMonths,
priceRials: priceRials ?? 0,
isFree,
expiresAt,
note: isFree ? 'ثبت اولیه (رایگان)' : 'ثبت اولیه',
createdById: userId,
},
});
}
await this.audit.log({
action: 'support.create',
entityType: 'support_subscription',
entityId: support.id,
tenantId,
userId,
after: this.publicSupport(support) as unknown as Prisma.InputJsonValue,
});
const tenant = await this.requireTenant(tenantId);
this.notify.notifyAdmins(
`سوپرادمین: پشتیبانی «${support.name}» برای نسخه «${tenant.nameFa}» اضافه شد.`,
);
return this.getSupport(support.id);
}
async update(supportId: string, dto: UpdateSupportDto, userId?: string) {
const before = await this.prisma.supportSubscription.findUnique({
where: { id: supportId },
});
if (!before) throw new NotFoundException('پشتیبانی یافت نشد');
if (dto.planMonths != null && !SUPPORT_PLAN_MONTHS.includes(dto.planMonths as any)) {
throw new BadRequestException('مدت اشتراک نامعتبر است');
}
let chargedAt = before.chargedAt;
let planMonths = before.planMonths;
let expiresAt = before.expiresAt;
if (dto.chargedAt !== undefined) {
chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : null;
}
if (dto.planMonths !== undefined) {
planMonths = dto.planMonths;
}
if (
(dto.chargedAt !== undefined || dto.planMonths !== undefined) &&
chargedAt &&
planMonths
) {
expiresAt = this.addMonths(chargedAt, planMonths);
}
const support = await this.prisma.supportSubscription.update({
where: { id: supportId },
data: {
...(dto.name !== undefined && { name: dto.name.trim() }),
...(dto.specs !== undefined && { specs: dto.specs?.trim() || null }),
...(dto.description !== undefined && {
description: dto.description?.trim() || null,
}),
...(dto.isFree !== undefined && { isFree: dto.isFree === true }),
...(dto.isFree === true
? { priceRials: 0 }
: dto.priceRials !== undefined
? { priceRials: dto.priceRials }
: {}),
...(dto.chargedAt !== undefined && { chargedAt }),
...(dto.planMonths !== undefined && { planMonths }),
...(dto.chargedAt !== undefined || dto.planMonths !== undefined
? { expiresAt }
: {}),
...(dto.alertDaysBefore !== undefined && {
alertDaysBefore: dto.alertDaysBefore,
}),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
},
});
await this.audit.log({
action: 'support.update',
entityType: 'support_subscription',
entityId: support.id,
tenantId: support.tenantId,
userId,
before: this.publicSupport(before) as unknown as Prisma.InputJsonValue,
after: this.publicSupport(support) as unknown as Prisma.InputJsonValue,
});
return this.getSupport(support.id);
}
async renew(supportId: string, dto: RenewSupportDto, userId?: string) {
if (!SUPPORT_PLAN_MONTHS.includes(dto.planMonths as any)) {
throw new BadRequestException('مدت اشتراک نامعتبر است');
}
const before = await this.prisma.supportSubscription.findUnique({
where: { id: supportId },
include: { tenant: true },
});
if (!before) throw new NotFoundException('پشتیبانی یافت نشد');
const isFree = dto.isFree === true;
const priceRials = isFree ? 0 : dto.priceRials;
const chargedAt = dto.chargedAt ? new Date(dto.chargedAt) : new Date();
if (Number.isNaN(chargedAt.getTime())) {
throw new BadRequestException('تاریخ شارژ نامعتبر است');
}
const expiresAt = this.addMonths(chargedAt, dto.planMonths);
const noteBase = dto.note?.trim() || null;
const note =
isFree && noteBase
? `${noteBase} (رایگان)`
: isFree
? 'رایگان'
: noteBase;
await this.prisma.$transaction([
this.prisma.supportSubscription.update({
where: { id: supportId },
data: {
chargedAt,
planMonths: dto.planMonths,
isFree,
priceRials,
expiresAt,
lastAlertDate: null,
isActive: true,
},
}),
this.prisma.supportCharge.create({
data: {
supportId,
chargedAt,
planMonths: dto.planMonths,
priceRials,
isFree,
expiresAt,
note,
createdById: userId,
},
}),
]);
await this.audit.log({
action: 'support.renew',
entityType: 'support_subscription',
entityId: supportId,
tenantId: before.tenantId,
userId,
after: {
name: before.name,
chargedAt: chargedAt.toISOString(),
planMonths: dto.planMonths,
isFree,
priceRials,
expiresAt: expiresAt.toISOString(),
} as unknown as Prisma.InputJsonValue,
});
this.notify.notifyAdmins(
`سوپرادمین: پشتیبانی «${before.name}» نسخه «${before.tenant.nameFa}» تمدید شد (${this.planLabel(dto.planMonths, isFree)}، تا ${formatIranDate(expiresAt)}).`,
);
return this.getSupport(supportId);
}
async remove(supportId: string, userId?: string) {
const before = await this.prisma.supportSubscription.findUnique({
where: { id: supportId },
include: { tenant: true },
});
if (!before) throw new NotFoundException('پشتیبانی یافت نشد');
await this.prisma.supportSubscription.delete({ where: { id: supportId } });
await this.audit.log({
action: 'support.delete',
entityType: 'support_subscription',
entityId: supportId,
tenantId: before.tenantId,
userId,
before: this.publicSupport(before) as unknown as Prisma.InputJsonValue,
});
this.notify.notifyAdmins(
`سوپرادمین: پشتیبانی «${before.name}» از نسخه «${before.tenant.nameFa}» حذف شد.`,
);
return { ok: true };
}
async runExpiryAlerts() {
try {
const supports = await this.prisma.supportSubscription.findMany({
where: {
isActive: true,
expiresAt: { not: null },
tenant: { isActive: true },
},
include: {
tenant: { select: { nameFa: true, domain: true } },
},
});
const todayKey = iranYmd(new Date());
let sent = 0;
for (const support of supports) {
if (!support.expiresAt) continue;
const daysLeft = iranDaysUntil(support.expiresAt);
const threshold = support.alertDaysBefore ?? 3;
if (daysLeft > threshold) continue;
if (support.lastAlertDate && iranYmd(support.lastAlertDate) === todayKey) {
continue;
}
const when =
daysLeft < 0
? `${Math.abs(daysLeft)} روز است منقضی شده`
: daysLeft === 0
? 'امروز منقضی می‌شود'
: `${daysLeft} روز تا پایان اعتبار`;
this.notify.notifyAdmins(
`هشدار پشتیبانی: «${support.name}» روی «${support.tenant.nameFa}» (${support.tenant.domain}) — ${when} (انقضا: ${formatIranDate(support.expiresAt)}).`,
);
await this.prisma.supportSubscription.update({
where: { id: support.id },
data: { lastAlertDate: new Date() },
});
sent += 1;
}
if (sent > 0) this.logger.log(`Support expiry alerts sent: ${sent}`);
} catch (error: any) {
this.logger.error(`Support alert failed: ${error?.message || error}`);
}
}
private toSummary(support: SupportSubscription) {
const planLabel = support.planMonths
? this.planLabel(support.planMonths, support.isFree)
: null;
if (!support.expiresAt) {
return {
name: support.name,
isActive: support.isActive,
isFree: support.isFree,
priceRials: support.priceRials,
chargedAt: support.chargedAt?.toISOString() ?? null,
planMonths: support.planMonths,
expiresAt: null as string | null,
daysLeft: null as number | null,
expiryLevel: 'unknown' as ExpiryLevel,
expiryLabel: 'بدون تاریخ اعتبار',
planLabel,
};
}
const daysLeft = iranDaysUntil(support.expiresAt);
const threshold = support.alertDaysBefore ?? 3;
let expiryLevel: ExpiryLevel = 'healthy';
let expiryLabel = `${daysLeft} روز مانده`;
if (daysLeft < 0) {
expiryLevel = 'critical';
expiryLabel = `${Math.abs(daysLeft)} روز گذشته از انقضا`;
} else if (daysLeft <= threshold) {
expiryLevel = 'critical';
expiryLabel =
daysLeft === 0 ? 'امروز منقضی می‌شود' : `${daysLeft} روز مانده`;
} else if (daysLeft <= threshold + 4) {
expiryLevel = 'warning';
expiryLabel = `${daysLeft} روز مانده`;
}
return {
name: support.name,
isActive: support.isActive,
isFree: support.isFree,
priceRials: support.priceRials,
chargedAt: support.chargedAt?.toISOString() ?? null,
planMonths: support.planMonths,
expiresAt: support.expiresAt.toISOString(),
daysLeft,
expiryLevel,
expiryLabel,
planLabel,
};
}
private worstLevel(levels: ExpiryLevel[]): ExpiryLevel {
if (!levels.length) return 'none';
const order: ExpiryLevel[] = ['critical', 'warning', 'unknown', 'healthy', 'none'];
for (const level of order) {
if (levels.includes(level)) return level;
}
return 'none';
}
private publicSupport(support: SupportSubscription) {
return {
id: support.id,
tenantId: support.tenantId,
name: support.name,
specs: support.specs,
isFree: support.isFree,
priceRials: support.priceRials,
chargedAt: support.chargedAt?.toISOString() ?? null,
planMonths: support.planMonths,
expiresAt: support.expiresAt?.toISOString() ?? null,
description: support.description,
alertDaysBefore: support.alertDaysBefore,
lastAlertDate: support.lastAlertDate?.toISOString() ?? null,
isActive: support.isActive,
updatedAt: support.updatedAt.toISOString(),
};
}
private planLabel(months: number, isFree = false) {
let label: string;
switch (months) {
case 1:
label = 'یک‌ماهه';
break;
case 3:
label = 'سه‌ماهه';
break;
case 6:
label = 'شش‌ماهه';
break;
case 12:
label = 'یک‌ساله';
break;
default:
label = `${months} ماهه`;
}
return isFree ? `${label} (رایگان)` : label;
}
private addMonths(from: Date, months: number) {
const d = new Date(from.getTime());
const day = d.getDate();
d.setMonth(d.getMonth() + months);
if (d.getDate() < day) d.setDate(0);
return d;
}
private async requireTenant(tenantId: string) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant) throw new NotFoundException('نسخه یافت نشد');
return tenant;
}
}