import { BadRequestException, Injectable, Logger, NotFoundException, OnModuleDestroy, OnModuleInit, } from '@nestjs/common'; import { PluginSubscription, 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 { CreatePluginDto, PLUGIN_PLAN_MONTHS, RenewPluginDto, UpdatePluginDto, } from './dto/plugin.dto'; export type ExpiryLevel = 'healthy' | 'warning' | 'critical' | 'unknown' | 'none'; @Injectable() export class PluginsService implements OnModuleInit, OnModuleDestroy { private readonly logger = new Logger(PluginsService.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: { pluginSubscriptions: { where: { isActive: true }, orderBy: { name: 'asc' }, }, }, }); return rows.map((tenant) => { const plugins = tenant.pluginSubscriptions.map((p) => this.toSummary(p)); const worst = this.worstLevel(plugins.map((p) => p.expiryLevel)); return { id: tenant.id, product: tenant.product, slug: tenant.slug, nameFa: tenant.nameFa, nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, pluginCount: plugins.length, plugins, summary: { pluginCount: plugins.length, expiryLevel: worst, expiryLabel: plugins.length === 0 ? 'بدون افزونه' : worst === 'critical' ? 'افزونه نزدیک انقضا / منقضی' : worst === 'warning' ? 'افزونه در بازه هشدار' : worst === 'unknown' ? 'افزونه بدون تاریخ' : `${plugins.length} افزونه فعال`, }, }; }); } async getExpiringSoon(limit = 5) { const plugins = await this.prisma.pluginSubscription.findMany({ where: { isActive: true, expiresAt: { not: null }, tenant: { isActive: true }, }, include: { tenant: { select: { id: true, product: true, nameFa: true, domain: true, slug: true, }, }, }, }); return plugins .map((p) => ({ ...this.toSummary(p), id: p.id, tenantId: p.tenantId, tenant: p.tenant, })) .filter((p) => { const level = p.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 getTenantPlugins(tenantId: string) { const tenant = await this.requireTenant(tenantId); const plugins = await this.prisma.pluginSubscription.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, }, plugins: plugins.map((p) => ({ ...this.publicPlugin(p), summary: this.toSummary(p), charges: p.charges, })), planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({ months: m, label: this.planLabel(m), })), }; } async getPlugin(pluginId: string) { const plugin = await this.prisma.pluginSubscription.findUnique({ where: { id: pluginId }, include: { tenant: true, charges: { orderBy: { createdAt: 'desc' }, take: 20 }, }, }); if (!plugin) throw new NotFoundException('افزونه یافت نشد'); return { tenant: { id: plugin.tenant.id, product: plugin.tenant.product, slug: plugin.tenant.slug, nameFa: plugin.tenant.nameFa, domain: plugin.tenant.domain, }, plugin: this.publicPlugin(plugin), summary: this.toSummary(plugin), charges: plugin.charges, planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({ months: m, label: this.planLabel(m), })), }; } async create(tenantId: string, dto: CreatePluginDto, userId?: string) { await this.requireTenant(tenantId); const name = dto.name.trim(); if (!name) throw new BadRequestException('نام افزونه لازم است'); 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 (!PLUGIN_PLAN_MONTHS.includes(planMonths as any)) { throw new BadRequestException('مدت اشتراک نامعتبر است'); } expiresAt = this.addMonths(chargedAt, planMonths); } const plugin = await this.prisma.pluginSubscription.create({ data: { tenantId, name, specs: dto.specs?.trim() || null, description: dto.description?.trim() || null, priceRials: dto.priceRials ?? null, chargedAt, planMonths, expiresAt, alertDaysBefore: dto.alertDaysBefore ?? 3, }, }); if (chargedAt && planMonths && expiresAt && dto.priceRials != null) { await this.prisma.pluginCharge.create({ data: { pluginId: plugin.id, chargedAt, planMonths, priceRials: dto.priceRials, expiresAt, note: 'ثبت اولیه', createdById: userId, }, }); } await this.audit.log({ action: 'plugin.create', entityType: 'plugin_subscription', entityId: plugin.id, tenantId, userId, after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue, }); const tenant = await this.requireTenant(tenantId); this.notify.notifyAdmins( `سوپرادمین: افزونه «${plugin.name}» برای نسخه «${tenant.nameFa}» اضافه شد.`, ); return this.getPlugin(plugin.id); } async update(pluginId: string, dto: UpdatePluginDto, userId?: string) { const before = await this.prisma.pluginSubscription.findUnique({ where: { id: pluginId }, }); if (!before) throw new NotFoundException('افزونه یافت نشد'); if (dto.planMonths != null && !PLUGIN_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 plugin = await this.prisma.pluginSubscription.update({ where: { id: pluginId }, 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.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: 'plugin.update', entityType: 'plugin_subscription', entityId: plugin.id, tenantId: plugin.tenantId, userId, before: this.publicPlugin(before) as unknown as Prisma.InputJsonValue, after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue, }); return this.getPlugin(plugin.id); } async renew(pluginId: string, dto: RenewPluginDto, userId?: string) { if (!PLUGIN_PLAN_MONTHS.includes(dto.planMonths as any)) { throw new BadRequestException('مدت اشتراک نامعتبر است'); } const before = await this.prisma.pluginSubscription.findUnique({ where: { id: pluginId }, include: { tenant: true }, }); if (!before) throw new NotFoundException('افزونه یافت نشد'); 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); await this.prisma.$transaction([ this.prisma.pluginSubscription.update({ where: { id: pluginId }, data: { chargedAt, planMonths: dto.planMonths, priceRials: dto.priceRials, expiresAt, lastAlertDate: null, isActive: true, }, }), this.prisma.pluginCharge.create({ data: { pluginId, chargedAt, planMonths: dto.planMonths, priceRials: dto.priceRials, expiresAt, note: dto.note?.trim() || null, createdById: userId, }, }), ]); await this.audit.log({ action: 'plugin.renew', entityType: 'plugin_subscription', entityId: pluginId, tenantId: before.tenantId, userId, after: { name: before.name, chargedAt: chargedAt.toISOString(), planMonths: dto.planMonths, priceRials: dto.priceRials, expiresAt: expiresAt.toISOString(), } as unknown as Prisma.InputJsonValue, }); this.notify.notifyAdmins( `سوپرادمین: افزونه «${before.name}» نسخه «${before.tenant.nameFa}» تمدید شد (${this.planLabel(dto.planMonths)}، تا ${formatIranDate(expiresAt)}).`, ); return this.getPlugin(pluginId); } async remove(pluginId: string, userId?: string) { const before = await this.prisma.pluginSubscription.findUnique({ where: { id: pluginId }, include: { tenant: true }, }); if (!before) throw new NotFoundException('افزونه یافت نشد'); await this.prisma.pluginSubscription.delete({ where: { id: pluginId } }); await this.audit.log({ action: 'plugin.delete', entityType: 'plugin_subscription', entityId: pluginId, tenantId: before.tenantId, userId, before: this.publicPlugin(before) as unknown as Prisma.InputJsonValue, }); this.notify.notifyAdmins( `سوپرادمین: افزونه «${before.name}» از نسخه «${before.tenant.nameFa}» حذف شد.`, ); return { ok: true }; } async runExpiryAlerts() { try { const plugins = await this.prisma.pluginSubscription.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 plugin of plugins) { if (!plugin.expiresAt) continue; const daysLeft = iranDaysUntil(plugin.expiresAt); const threshold = plugin.alertDaysBefore ?? 3; if (daysLeft > threshold) continue; if (plugin.lastAlertDate && iranYmd(plugin.lastAlertDate) === todayKey) { continue; } const when = daysLeft < 0 ? `${Math.abs(daysLeft)} روز است منقضی شده` : daysLeft === 0 ? 'امروز منقضی می‌شود' : `${daysLeft} روز تا پایان اعتبار`; this.notify.notifyAdmins( `هشدار افزونه: «${plugin.name}» روی «${plugin.tenant.nameFa}» (${plugin.tenant.domain}) — ${when} (انقضا: ${formatIranDate(plugin.expiresAt)}).`, ); await this.prisma.pluginSubscription.update({ where: { id: plugin.id }, data: { lastAlertDate: new Date() }, }); sent += 1; } if (sent > 0) this.logger.log(`Plugin expiry alerts sent: ${sent}`); } catch (error: any) { this.logger.error(`Plugin alert failed: ${error?.message || error}`); } } private toSummary(plugin: PluginSubscription) { if (!plugin.expiresAt) { return { name: plugin.name, isActive: plugin.isActive, priceRials: plugin.priceRials, chargedAt: plugin.chargedAt?.toISOString() ?? null, planMonths: plugin.planMonths, expiresAt: null as string | null, daysLeft: null as number | null, expiryLevel: 'unknown' as ExpiryLevel, expiryLabel: 'بدون تاریخ اعتبار', planLabel: plugin.planMonths ? this.planLabel(plugin.planMonths) : null, }; } const daysLeft = iranDaysUntil(plugin.expiresAt); const threshold = plugin.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: plugin.name, isActive: plugin.isActive, priceRials: plugin.priceRials, chargedAt: plugin.chargedAt?.toISOString() ?? null, planMonths: plugin.planMonths, expiresAt: plugin.expiresAt.toISOString(), daysLeft, expiryLevel, expiryLabel, planLabel: plugin.planMonths ? this.planLabel(plugin.planMonths) : null, }; } 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 publicPlugin(plugin: PluginSubscription) { return { id: plugin.id, tenantId: plugin.tenantId, name: plugin.name, specs: plugin.specs, priceRials: plugin.priceRials, chargedAt: plugin.chargedAt?.toISOString() ?? null, planMonths: plugin.planMonths, expiresAt: plugin.expiresAt?.toISOString() ?? null, description: plugin.description, alertDaysBefore: plugin.alertDaysBefore, lastAlertDate: plugin.lastAlertDate?.toISOString() ?? null, isActive: plugin.isActive, updatedAt: plugin.updatedAt.toISOString(), }; } private planLabel(months: number) { switch (months) { case 1: return 'یک‌ماهه'; case 3: return 'سه‌ماهه'; case 6: return 'شش‌ماهه'; case 12: return 'یک‌ساله'; default: return `${months} ماهه`; } } 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; } }