Add production-ready SuperAdmin API features for tenants, SMS, servers, and plugins.

Includes mobile auth, get_user/get_version, SMS wallet parity endpoints, appearance backfill, and related Prisma migrations.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-07-30 18:19:53 +03:30
co-authored by Cursor
parent ddf36f8470
commit a6b3457779
54 changed files with 3770 additions and 156 deletions
+121
View File
@@ -0,0 +1,121 @@
import {
IsBoolean,
IsIn,
IsInt,
IsISO8601,
IsOptional,
IsString,
Max,
MaxLength,
Min,
MinLength,
} from 'class-validator';
import { Type } from 'class-transformer';
export const PLUGIN_PLAN_MONTHS = [1, 3, 6, 12] as const;
export class CreatePluginDto {
@IsString()
@MinLength(2)
@MaxLength(120)
name!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
specs?: string;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
priceRials?: number;
@IsOptional()
@IsISO8601()
chargedAt?: string;
@IsOptional()
@Type(() => Number)
@IsInt()
@IsIn(PLUGIN_PLAN_MONTHS as unknown as number[])
planMonths?: number;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(30)
alertDaysBefore?: number;
}
export class UpdatePluginDto {
@IsOptional()
@IsString()
@MinLength(2)
@MaxLength(120)
name?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
specs?: string | null;
@IsOptional()
@IsString()
@MaxLength(4000)
description?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
priceRials?: number | null;
@IsOptional()
@IsISO8601()
chargedAt?: string | null;
@IsOptional()
@Type(() => Number)
@IsInt()
@IsIn(PLUGIN_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 RenewPluginDto {
@IsOptional()
@IsISO8601()
chargedAt?: string;
@Type(() => Number)
@IsInt()
@IsIn(PLUGIN_PLAN_MONTHS as unknown as number[])
planMonths!: number;
@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 {
CreatePluginDto,
RenewPluginDto,
UpdatePluginDto,
} from './dto/plugin.dto';
import { PluginsService } from './plugins.service';
@Controller('plugins')
@UseGuards(JwtAuthGuard)
export class PluginsController {
constructor(private readonly pluginsService: PluginsService) {}
@Get('tenants')
listTenants() {
return this.pluginsService.listTenants();
}
@Get('expiring')
expiring(@Query('limit') limit?: string) {
return this.pluginsService.getExpiringSoon(Number(limit) || 5);
}
@Get('tenants/:tenantId')
getTenantPlugins(@Param('tenantId') tenantId: string) {
return this.pluginsService.getTenantPlugins(tenantId);
}
@Post('tenants/:tenantId')
create(
@Param('tenantId') tenantId: string,
@Body() dto: CreatePluginDto,
@CurrentUser() user: { id: string },
) {
return this.pluginsService.create(tenantId, dto, user.id);
}
@Get('items/:id')
getOne(@Param('id') id: string) {
return this.pluginsService.getPlugin(id);
}
@Patch('items/:id')
update(
@Param('id') id: string,
@Body() dto: UpdatePluginDto,
@CurrentUser() user: { id: string },
) {
return this.pluginsService.update(id, dto, user.id);
}
@Post('items/:id/renew')
renew(
@Param('id') id: string,
@Body() dto: RenewPluginDto,
@CurrentUser() user: { id: string },
) {
return this.pluginsService.renew(id, dto, user.id);
}
@Delete('items/:id')
remove(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.pluginsService.remove(id, user.id);
}
}
+12
View File
@@ -0,0 +1,12 @@
import { Module } from '@nestjs/common';
import { NotificationsModule } from '../notifications/notifications.module';
import { PluginsController } from './plugins.controller';
import { PluginsService } from './plugins.service';
@Module({
imports: [NotificationsModule],
controllers: [PluginsController],
providers: [PluginsService],
exports: [PluginsService],
})
export class PluginsModule {}
+557
View File
@@ -0,0 +1,557 @@
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;
}
}