Add support subscription panel API with optional free periods.
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -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;
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
|||||||
@@ -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],
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user