diff --git a/AGENTS.md b/AGENTS.md index 7170ff8..32b19cb 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -43,6 +43,8 @@ Workspace پیشنهادی: `E:\projeha\Abshode-suite.code-workspace` - مدیران سوپرادمین: نام / نام‌خانوادگی / موبایل / رمز؛ ورود با موبایل؛ صفحه `/admins` - پیامک هشدار مدیران از API مرکزی اس‌ام‌اس رایگان (`CENTRAL_SMS_*` → `createsms`)؛ رویدادهای نسخه و کیف پول SMS - پنل سرور: هر نسخه ۰..N سرور (نام/مشخصات جدا)؛ هر کدام اختصاصی یا مدیریت‌شده؛ قیمت/مدت (۱،۳،۶،۱۲ ماه)؛ هشدار SMS نزدیک انقضا (پیش‌فرض ۳ روز، روزانه تا تمدید) +- پنل پشتیبانی: هر نسخه ۰..N اشتراک پشتیبانی؛ مدت ۱|۳|۶|۱۲ ماه؛ امکان دوره رایگان (isFree، قیمت ۰)؛ هشدار انقضا مثل افزونه +- پنل افزونه: هر نسخه ۰..N افزونه؛ قیمت/مدت/هشدار انقضا - پنل افزونه: هر نسخه ۰..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/20260808110000_support_subscriptions/migration.sql b/prisma/migrations/20260808110000_support_subscriptions/migration.sql new file mode 100644 index 0000000..adb1b5c --- /dev/null +++ b/prisma/migrations/20260808110000_support_subscriptions/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index c279e2c..db1596b 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -74,6 +74,7 @@ model Tenant { /// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL) serverAccounts ServerAccount[] pluginSubscriptions PluginSubscription[] + supportSubscriptions SupportSubscription[] @@index([product]) @@index([isActive]) @@ -279,3 +280,53 @@ model PluginCharge { @@index([createdAt]) @@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") +} diff --git a/src/app.module.ts b/src/app.module.ts index be1ea3d..6834add 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -10,6 +10,7 @@ import { AuditModule } from './audit/audit.module'; import { SmsModule } from './sms/sms.module'; import { ServersModule } from './servers/servers.module'; import { PluginsModule } from './plugins/plugins.module'; +import { SupportModule } from './support/support.module'; import { NotificationsModule } from './notifications/notifications.module'; import { AdminsModule } from './admins/admins.module'; import { HealthController } from './health.controller'; @@ -28,6 +29,7 @@ import { HealthController } from './health.controller'; SmsModule, ServersModule, PluginsModule, + SupportModule, AdminsModule, ], controllers: [HealthController], diff --git a/src/support/dto/support.dto.ts b/src/support/dto/support.dto.ts new file mode 100644 index 0000000..015c08e --- /dev/null +++ b/src/support/dto/support.dto.ts @@ -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; +} diff --git a/src/support/support.controller.ts b/src/support/support.controller.ts new file mode 100644 index 0000000..031e3a4 --- /dev/null +++ b/src/support/support.controller.ts @@ -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); + } +} diff --git a/src/support/support.module.ts b/src/support/support.module.ts new file mode 100644 index 0000000..737364b --- /dev/null +++ b/src/support/support.module.ts @@ -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 {} diff --git a/src/support/support.service.ts b/src/support/support.service.ts new file mode 100644 index 0000000..9593270 --- /dev/null +++ b/src/support/support.service.ts @@ -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; + } +}