feat(plugins): sell khanegi plugins as priced subscriptions

Catalog prices, purchase requests, and setting-key activation live on SuperAdmin so module admins can buy add-ons. Also rewrite legacy nerkh logo hosts to the SuperAdmin upload origin.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-07 16:56:51 +03:30
co-authored by Cursor
parent ccf7e90922
commit 1f776cf6e2
16 changed files with 1386 additions and 14 deletions
+98
View File
@@ -0,0 +1,98 @@
import {
IsArray,
IsBoolean,
IsIn,
IsInt,
IsOptional,
IsString,
Matches,
MaxLength,
Min,
MinLength,
ValidateNested,
} from 'class-validator';
import { Type } from 'class-transformer';
import { PLUGIN_STORE_DURATIONS } from '../plugin-catalog.seed';
export class CatalogPriceInputDto {
@Type(() => Number)
@IsInt()
@IsIn(PLUGIN_STORE_DURATIONS as unknown as number[])
durationDays!: number;
@IsBoolean()
enabled!: boolean;
@Type(() => Number)
@IsInt()
@Min(0)
originalPriceRials!: number;
@Type(() => Number)
@IsInt()
@Min(0)
finalPriceRials!: number;
}
export class SaveCatalogItemDto {
@IsOptional()
@IsBoolean()
inStore?: boolean;
@IsOptional()
@IsString()
@MaxLength(120)
titleFa?: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string | null;
@IsArray()
@ValidateNested({ each: true })
@Type(() => CatalogPriceInputDto)
prices!: CatalogPriceInputDto[];
}
export class CreateCatalogSettingDto {
@IsString()
@MinLength(3)
@MaxLength(50)
@Matches(/^[a-z][a-z0-9_]*$/)
settingKey!: string;
@IsString()
@MinLength(2)
@MaxLength(120)
titleFa!: string;
@IsOptional()
@IsString()
@MaxLength(2000)
description?: string;
@IsOptional()
@IsBoolean()
inStore?: boolean;
}
export class CreatePurchaseRequestDto {
@IsString()
catalogItemId!: string;
@Type(() => Number)
@IsInt()
@IsIn(PLUGIN_STORE_DURATIONS as unknown as number[])
durationDays!: number;
@Type(() => Number)
@IsInt()
@Min(0)
finalPriceRials!: number;
}
export class ApprovePurchaseDto {
@IsBoolean()
autoCutOnExpiry!: boolean;
}
+18
View File
@@ -52,6 +52,15 @@ export class CreatePluginDto {
@Min(1)
@Max(30)
alertDaysBefore?: number;
@IsOptional()
@IsString()
@MaxLength(50)
settingKey?: string;
@IsOptional()
@IsBoolean()
autoCutOnExpiry?: boolean;
}
export class UpdatePluginDto {
@@ -97,6 +106,15 @@ export class UpdatePluginDto {
@IsOptional()
@IsBoolean()
isActive?: boolean;
@IsOptional()
@IsString()
@MaxLength(50)
settingKey?: string | null;
@IsOptional()
@IsBoolean()
autoCutOnExpiry?: boolean;
}
export class RenewPluginDto {
+114
View File
@@ -0,0 +1,114 @@
export const PLUGIN_STORE_DURATIONS = [30, 90, 180, 360] as const;
export type PluginStoreDuration = (typeof PLUGIN_STORE_DURATIONS)[number];
export const PLUGIN_CATALOG_SEED: Array<{
settingKey: string;
titleFa: string;
description: string;
}> = [
{
settingKey: 'jibit_enabled',
titleFa: 'احراز هویت جیبیت',
description:
'سرویس جیبیت برای تطبیق کد ملی، موبایل و کارت بانکی.',
},
{
settingKey: 'enable_gold_credit',
titleFa: 'اعتبار طلایی',
description: 'منوی اعتبار و صفحه اعتبار مشتری.',
},
{
settingKey: 'enable_automation',
titleFa: 'اتوماسیون',
description: 'منوی اتوماسیون در ادمین و موتور پس‌زمینه.',
},
{
settingKey: 'group_sms',
titleFa: 'پیامک گروهی',
description: 'ارسال پیامک گروهی از پنل ادمین.',
},
{
settingKey: 'customer_direct_sms',
titleFa: 'پیامک تکی به مشتری از لیست',
description: 'ارسال متن دلخواه به یک مشتری از لیست.',
},
{
settingKey: 'has_page_notification',
titleFa: 'اعلان داخل اپ',
description: 'زنگوله اعلان در اپ مشتری و منوی ثبت در ادمین.',
},
{
settingKey: 'has_page_earn_money',
titleFa: 'صفحه کسب درآمد',
description: 'منوی کسب درآمد / دعوت دوستان در اپ مشتری.',
},
{
settingKey: 'enable_customer_level_profit',
titleFa: 'سود چندسطحی معرف',
description: 'درصد سود زیرمجموعه پس از سفارش.',
},
{
settingKey: 'enable_conditional_order',
titleFa: 'سفارش شرطی',
description: 'سفارش شرطی سراسری.',
},
{
settingKey: 'has_page_gift_card',
titleFa: 'کارت هدیه',
description: 'منو و صفحه کارت هدیه در اپ مشتری.',
},
{
settingKey: 'customer_special_pricing',
titleFa: 'قیمت ویژه مشتری',
description: 'حاشیه سود/زیان قیمت برای هر مشتری.',
},
{
settingKey: 'enable_product_funds',
titleFa: 'قلک محصول',
description: 'ساخت و مدیریت قلک per-product در اپ مشتری.',
},
{
settingKey: 'has_page_price_trigger',
titleFa: 'هشدار قیمت',
description: 'منوی هشدار قیمت در اپ مشتری.',
},
{
settingKey: 'show_to_users_chat',
titleFa: 'نمایش چت به کاربران',
description: 'نمایش چت پشتیبانی در اپ مشتری.',
},
];
export function daysToPlanMonths(days: number): 1 | 3 | 6 | 12 {
if (days === 90) return 3;
if (days === 180) return 6;
if (days === 360) return 12;
return 1;
}
export function inferSettingKeyFromName(name: string): string | null {
const n = String(name || '').trim();
if (!n) return null;
const rules: Array<[RegExp, string]> = [
[/جیبیت/, 'jibit_enabled'],
[/اعتبار طلایی/, 'enable_gold_credit'],
[/اتوماسیون/, 'enable_automation'],
[/پیامک گروهی/, 'group_sms'],
[/پیامک تکی/, 'customer_direct_sms'],
[/اعلان/, 'has_page_notification'],
[/کسب درآمد/, 'has_page_earn_money'],
[/سود.*معرف|معرف/, 'enable_customer_level_profit'],
[/سفارش شرطی/, 'enable_conditional_order'],
[/کارت هدیه/, 'has_page_gift_card'],
[/قیمت ویژه/, 'customer_special_pricing'],
[/قلک/, 'enable_product_funds'],
[/هشدار قیمت/, 'has_page_price_trigger'],
[/نمایش چت|چت به کاربر/, 'show_to_users_chat'],
];
for (const [re, key] of rules) {
if (re.test(n)) return key;
}
const seed = PLUGIN_CATALOG_SEED.find((row) => row.titleFa === n);
return seed?.settingKey ?? null;
}
+64
View File
@@ -0,0 +1,64 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { JwtAuthGuard } from '../auth/jwt-auth.guard';
import { PermissionsGuard } from '../auth/permissions.guard';
import { RequirePermissions } from '../auth/permissions.decorator';
import { CurrentUser } from '../auth/current-user.decorator';
import {
ApprovePurchaseDto,
CreateCatalogSettingDto,
SaveCatalogItemDto,
} from './dto/plugin-store.dto';
import { PluginStoreService } from './plugin-store.service';
@Controller('plugins/store')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class PluginStoreController {
constructor(private readonly store: PluginStoreService) {}
@Get('catalog')
@RequirePermissions('plugins:read')
catalog() {
return this.store.listCatalogAdmin();
}
@Patch('catalog/:id')
@RequirePermissions('plugins:write')
saveItem(
@Param('id') id: string,
@Body() dto: SaveCatalogItemDto,
@CurrentUser() user: { id: string },
) {
return this.store.saveCatalogItem(id, dto, user.id);
}
@Post('catalog')
@RequirePermissions('plugins:write')
createSetting(
@Body() dto: CreateCatalogSettingDto,
@CurrentUser() user: { id: string },
) {
return this.store.createCatalogSetting(dto, user.id);
}
@Get('requests')
@RequirePermissions('plugins:read')
requests(@Query('status') status?: string) {
return this.store.listRequestsAdmin(status);
}
@Post('requests/:id/approve')
@RequirePermissions('plugins:write')
approve(
@Param('id') id: string,
@Body() dto: ApprovePurchaseDto,
@CurrentUser() user: { id: string },
) {
return this.store.approveRequest(id, dto, user.id);
}
@Post('requests/:id/reject')
@RequirePermissions('plugins:write')
reject(@Param('id') id: string, @CurrentUser() user: { id: string }) {
return this.store.rejectRequest(id, user.id);
}
}
+755
View File
@@ -0,0 +1,755 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
Logger,
NotFoundException,
OnModuleDestroy,
OnModuleInit,
} from '@nestjs/common';
import {
PluginCatalogItem,
PluginCatalogPrice,
PluginPurchaseStatus,
Prisma,
ProductType,
} from '@prisma/client';
import axios from 'axios';
import { ConfigService } from '@nestjs/config';
import { PrismaService } from '../prisma/prisma.service';
import { AuditService } from '../audit/audit.service';
import { AdminNotifyService } from '../notifications/admin-notify.service';
import { CentralSmsService } from '../notifications/central-sms.service';
import { RuntimeSettingsService } from '../notifications/runtime-settings.service';
import { ProgramSettingsService } from '../program-settings/program-settings.service';
import {
addIranMonths,
addIranPlanExpiry,
formatIranDate,
iranDaysUntil,
} from '../common/iran-time.util';
import {
daysToPlanMonths,
inferSettingKeyFromName,
PLUGIN_CATALOG_SEED,
PLUGIN_STORE_DURATIONS,
} from './plugin-catalog.seed';
import {
ApprovePurchaseDto,
CreateCatalogSettingDto,
CreatePurchaseRequestDto,
SaveCatalogItemDto,
} from './dto/plugin-store.dto';
type CatalogWithPrices = PluginCatalogItem & { prices: PluginCatalogPrice[] };
@Injectable()
export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
private readonly logger = new Logger(PluginStoreService.name);
private timer: NodeJS.Timeout | null = null;
private mappedOnce = false;
constructor(
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly notify: AdminNotifyService,
private readonly sms: CentralSmsService,
private readonly runtime: RuntimeSettingsService,
private readonly programSettings: ProgramSettingsService,
private readonly config: ConfigService,
) {}
onModuleInit() {
void this.bootstrap();
setTimeout(() => void this.runKhanegiExpiry(), 25_000);
this.timer = setInterval(() => void this.runKhanegiExpiry(), 6 * 60 * 60 * 1000);
}
onModuleDestroy() {
if (this.timer) clearInterval(this.timer);
}
private async bootstrap() {
try {
await this.seedCatalog();
await this.programSettings.refreshExtraOwnedKeys();
await this.mapExistingKhanegiPlugins();
} catch (error: any) {
this.logger.warn(`plugin store bootstrap skipped: ${error?.message || error}`);
}
}
async seedCatalog() {
for (const row of PLUGIN_CATALOG_SEED) {
const existing = await this.prisma.pluginCatalogItem.findUnique({
where: { settingKey: row.settingKey },
});
if (existing) continue;
await this.prisma.pluginCatalogItem.create({
data: {
settingKey: row.settingKey,
titleFa: row.titleFa,
description: row.description,
inStore: true,
prices: {
create: PLUGIN_STORE_DURATIONS.map((durationDays) => ({
durationDays,
enabled: false,
originalPriceRials: 0,
finalPriceRials: 0,
})),
},
},
});
}
}
async mapExistingKhanegiPlugins() {
if (this.mappedOnce) return;
const rows = await this.prisma.pluginSubscription.findMany({
where: {
settingKey: null,
tenant: { product: ProductType.khanegi },
},
include: { tenant: { select: { product: true } } },
});
const catalog = await this.prisma.pluginCatalogItem.findMany({
select: { settingKey: true, titleFa: true },
});
const byTitle = new Map(catalog.map((c) => [c.titleFa, c.settingKey]));
for (const row of rows) {
const key =
byTitle.get(row.name.trim()) || inferSettingKeyFromName(row.name);
if (!key) continue;
await this.prisma.pluginSubscription.update({
where: { id: row.id },
data: { settingKey: key },
});
}
this.mappedOnce = true;
}
inferKey(name: string) {
return inferSettingKeyFromName(name);
}
async applySettingSwitch(tenantId: string, settingKey: string, on: boolean) {
const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } });
if (!tenant || tenant.product !== ProductType.khanegi) return;
try {
await this.programSettings.setOwnedSwitch(tenantId, settingKey, on);
} catch (error: any) {
this.logger.warn(
`setOwnedSwitch ${settingKey}=${on} tenant=${tenantId}: ${error?.message || error}`,
);
}
}
async listCatalogAdmin() {
await this.seedCatalog();
const items = await this.prisma.pluginCatalogItem.findMany({
orderBy: { titleFa: 'asc' },
include: { prices: { orderBy: { durationDays: 'asc' } } },
});
return items.map((item) => this.toCatalogAdmin(item));
}
async saveCatalogItem(id: string, dto: SaveCatalogItemDto, userId?: string) {
const item = await this.prisma.pluginCatalogItem.findUnique({
where: { id },
include: { prices: true },
});
if (!item) throw new NotFoundException('افزونه کاتالوگ یافت نشد');
this.assertPrices(dto.prices);
await this.prisma.$transaction(async (tx: any) => {
await tx.pluginCatalogItem.update({
where: { id },
data: {
...(dto.inStore !== undefined && { inStore: dto.inStore }),
...(dto.titleFa !== undefined && { titleFa: dto.titleFa.trim() }),
...(dto.description !== undefined && {
description: dto.description?.trim() || null,
}),
},
});
for (const price of dto.prices) {
await tx.pluginCatalogPrice.upsert({
where: {
catalogItemId_durationDays: {
catalogItemId: id,
durationDays: price.durationDays,
},
},
create: {
catalogItemId: id,
durationDays: price.durationDays,
enabled: price.enabled,
originalPriceRials: price.originalPriceRials,
finalPriceRials: price.finalPriceRials,
},
update: {
enabled: price.enabled,
originalPriceRials: price.originalPriceRials,
finalPriceRials: price.finalPriceRials,
},
});
}
});
await this.audit.log({
action: 'plugin_catalog.update',
entityType: 'plugin_catalog_item',
entityId: id,
userId,
after: { settingKey: item.settingKey } as Prisma.InputJsonValue,
});
return this.getCatalogAdmin(id);
}
async createCatalogSetting(dto: CreateCatalogSettingDto, userId?: string) {
const key = dto.settingKey.trim();
const exists = await this.prisma.pluginCatalogItem.findUnique({
where: { settingKey: key },
});
if (exists) throw new ConflictException('این کلید از قبل در کاتالوگ است');
const item = await this.prisma.pluginCatalogItem.create({
data: {
settingKey: key,
titleFa: dto.titleFa.trim(),
description: dto.description?.trim() || null,
inStore: dto.inStore !== false,
prices: {
create: PLUGIN_STORE_DURATIONS.map((durationDays) => ({
durationDays,
enabled: false,
originalPriceRials: 0,
finalPriceRials: 0,
})),
},
},
include: { prices: true },
});
await this.programSettings.provisionOwnedSwitch({
key,
titleFa: dto.titleFa.trim(),
description: dto.description?.trim() || '',
});
await this.audit.log({
action: 'plugin_catalog.create',
entityType: 'plugin_catalog_item',
entityId: item.id,
userId,
after: { settingKey: key } as Prisma.InputJsonValue,
});
return this.toCatalogAdmin(item);
}
async listRequestsAdmin(status?: string) {
const where: Prisma.PluginPurchaseRequestWhereInput = {
tenant: { product: ProductType.khanegi },
};
if (status && ['pending', 'approved', 'rejected', 'cancelled'].includes(status)) {
where.status = status as PluginPurchaseStatus;
}
const rows = await this.prisma.pluginPurchaseRequest.findMany({
where,
orderBy: { createdAt: 'desc' },
include: {
tenant: {
select: { id: true, nameFa: true, domain: true, slug: true, product: true },
},
catalogItem: { select: { id: true, titleFa: true, settingKey: true } },
},
});
return rows.map((row) => this.toRequestRow(row));
}
async approveRequest(id: string, dto: ApprovePurchaseDto, userId?: string) {
const req = await this.prisma.pluginPurchaseRequest.findUnique({
where: { id },
include: {
tenant: true,
catalogItem: true,
},
});
if (!req) throw new NotFoundException('درخواست یافت نشد');
if (req.tenant.product !== ProductType.khanegi) {
throw new ForbiddenException('فقط درخواست خانگی');
}
if (req.status !== 'pending') {
throw new BadRequestException('این درخواست قابل تأیید نیست');
}
const months = daysToPlanMonths(req.durationDays);
const existing = await this.prisma.pluginSubscription.findFirst({
where: {
tenantId: req.tenantId,
settingKey: req.catalogItem.settingKey,
},
orderBy: { expiresAt: 'desc' },
});
const now = new Date();
const from =
existing?.expiresAt && existing.expiresAt.getTime() > now.getTime()
? existing.expiresAt
: now;
const expiresAt =
existing?.expiresAt && existing.expiresAt.getTime() > now.getTime()
? addIranMonths(from, months)
: addIranPlanExpiry(now, months);
const plugin = existing
? await this.prisma.pluginSubscription.update({
where: { id: existing.id },
data: {
name: req.catalogItem.titleFa,
specs: req.catalogItem.description,
settingKey: req.catalogItem.settingKey,
planMonths: months,
priceRials: req.finalPriceRials,
chargedAt: now,
expiresAt,
isActive: true,
autoCutOnExpiry: dto.autoCutOnExpiry,
lastAlertDate: null,
alertMarks: Prisma.DbNull,
},
})
: await this.prisma.pluginSubscription.create({
data: {
tenantId: req.tenantId,
name: req.catalogItem.titleFa,
specs: req.catalogItem.description,
settingKey: req.catalogItem.settingKey,
planMonths: months,
priceRials: req.finalPriceRials,
chargedAt: now,
expiresAt,
isActive: true,
autoCutOnExpiry: dto.autoCutOnExpiry,
},
});
await this.prisma.pluginCharge.create({
data: {
pluginId: plugin.id,
chargedAt: now,
planMonths: months,
priceRials: req.finalPriceRials,
expiresAt,
note: 'خرید از فروشگاه',
createdById: userId,
},
});
await this.prisma.pluginPurchaseRequest.update({
where: { id },
data: {
status: 'approved',
autoCutOnExpiry: dto.autoCutOnExpiry,
pluginSubscriptionId: plugin.id,
decidedAt: now,
},
});
await this.applySettingSwitch(req.tenantId, req.catalogItem.settingKey, true);
await this.audit.log({
action: 'plugin_request.approve',
entityType: 'plugin_purchase_request',
entityId: id,
tenantId: req.tenantId,
userId,
after: { settingKey: req.catalogItem.settingKey, expiresAt: expiresAt.toISOString() } as Prisma.InputJsonValue,
});
return { ok: true, expiresAt: expiresAt.toISOString() };
}
async rejectRequest(id: string, userId?: string) {
const req = await this.prisma.pluginPurchaseRequest.findUnique({ where: { id } });
if (!req) throw new NotFoundException('درخواست یافت نشد');
if (req.status !== 'pending') {
throw new BadRequestException('این درخواست قابل رد نیست');
}
await this.prisma.pluginPurchaseRequest.update({
where: { id },
data: { status: 'rejected', decidedAt: new Date() },
});
await this.audit.log({
action: 'plugin_request.reject',
entityType: 'plugin_purchase_request',
entityId: id,
tenantId: req.tenantId,
userId,
});
return { ok: true };
}
remoteStoreEnabled() {
return this.runtime.getPluginStoreEnabled();
}
async remoteStore(tenantId: string, product: string) {
this.assertRemoteKhanegi(tenantId, product);
const items = await this.prisma.pluginCatalogItem.findMany({
where: { inStore: true },
orderBy: { titleFa: 'asc' },
include: { prices: { orderBy: { durationDays: 'asc' } } },
});
const subs = await this.prisma.pluginSubscription.findMany({
where: { tenantId, settingKey: { not: null } },
});
const pending = await this.prisma.pluginPurchaseRequest.findMany({
where: { tenantId, status: 'pending' },
select: { catalogItemId: true },
});
const pendingIds = new Set(pending.map((p) => p.catalogItemId));
const now = Date.now();
return {
plugin_store_enabled: true,
items: items
.map((item) => {
const enabledPrices = item.prices.filter((p) => p.enabled);
if (!enabledPrices.length) return null;
const sub = subs
.filter((s) => s.settingKey === item.settingKey)
.sort((a, b) => (b.expiresAt?.getTime() || 0) - (a.expiresAt?.getTime() || 0))[0];
const daysLeft = sub?.expiresAt ? iranDaysUntil(sub.expiresAt) : null;
const hasPending = pendingIds.has(item.id);
const lockedByDays =
sub?.isActive &&
sub.expiresAt &&
sub.expiresAt.getTime() > now &&
daysLeft != null &&
daysLeft > 3;
return {
id: item.id,
settingKey: item.settingKey,
titleFa: item.titleFa,
description: item.description,
prices: enabledPrices.map((p) => ({
durationDays: p.durationDays,
originalPriceRials: p.originalPriceRials,
finalPriceRials: p.finalPriceRials,
discounted: p.finalPriceRials < p.originalPriceRials,
free: p.finalPriceRials === 0,
})),
subscription: sub
? {
expiresAt: sub.expiresAt?.toISOString() ?? null,
daysLeft,
isActive: sub.isActive,
}
: null,
canBuy: !hasPending && !lockedByDays,
pendingRequest: hasPending,
};
})
.filter(Boolean),
};
}
async remoteListRequests(tenantId: string, product: string) {
this.assertRemoteKhanegi(tenantId, product);
const rows = await this.prisma.pluginPurchaseRequest.findMany({
where: { tenantId },
orderBy: { createdAt: 'desc' },
include: {
catalogItem: { select: { titleFa: true, settingKey: true } },
tenant: { select: { nameFa: true, domain: true, product: true, slug: true, id: true } },
},
});
return rows.map((row) => this.toRequestRow(row));
}
async remoteCreateRequest(
tenantId: string,
product: string,
dto: CreatePurchaseRequestDto,
) {
this.assertRemoteKhanegi(tenantId, product);
const item = await this.prisma.pluginCatalogItem.findUnique({
where: { id: dto.catalogItemId },
include: { prices: true },
});
if (!item || !item.inStore) throw new NotFoundException('افزونه در فروشگاه نیست');
const price = item.prices.find((p) => p.durationDays === dto.durationDays && p.enabled);
if (!price) throw new BadRequestException('این مدت برای افزونه فعال نیست');
if (price.finalPriceRials !== dto.finalPriceRials) {
throw new BadRequestException('قیمت عوض شده؛ صفحه را تازه کنید');
}
const existingPending = await this.prisma.pluginPurchaseRequest.findFirst({
where: { tenantId, catalogItemId: item.id, status: 'pending' },
});
if (existingPending) {
throw new ConflictException('درخواست در انتظار برای این افزونه وجود دارد');
}
const sub = await this.prisma.pluginSubscription.findFirst({
where: { tenantId, settingKey: item.settingKey, isActive: true },
orderBy: { expiresAt: 'desc' },
});
if (sub?.expiresAt && sub.expiresAt.getTime() > Date.now()) {
const daysLeft = iranDaysUntil(sub.expiresAt);
if (daysLeft > 3) {
throw new ConflictException('تا ۳ روز مانده به انقضا نمی‌توان تمدید کرد');
}
}
const created = await this.prisma.pluginPurchaseRequest.create({
data: {
tenantId,
catalogItemId: item.id,
durationDays: dto.durationDays,
originalPriceRials: price.originalPriceRials,
finalPriceRials: price.finalPriceRials,
status: 'pending',
},
include: {
catalogItem: { select: { titleFa: true, settingKey: true } },
tenant: { select: { nameFa: true, domain: true } },
},
});
this.notify.notifyAdmins(
`درخواست افزونه: «${created.catalogItem.titleFa}» ${dto.durationDays}روزه برای «${created.tenant.nameFa}» (${created.tenant.domain}) — ${price.finalPriceRials.toLocaleString('fa-IR')} ریال`,
);
return this.toRequestRow({
...created,
tenant: {
id: tenantId,
nameFa: created.tenant.nameFa,
domain: created.tenant.domain,
slug: '',
product: ProductType.khanegi,
},
} as any);
}
async remoteCancelRequest(tenantId: string, product: string, requestId: string) {
this.assertRemoteKhanegi(tenantId, product);
const req = await this.prisma.pluginPurchaseRequest.findFirst({
where: { id: requestId, tenantId },
});
if (!req) throw new NotFoundException('درخواست یافت نشد');
if (req.status !== 'pending') {
throw new BadRequestException('فقط درخواست در انتظار قابل لغو است');
}
await this.prisma.pluginPurchaseRequest.update({
where: { id: requestId },
data: { status: 'cancelled', decidedAt: new Date() },
});
return { ok: true };
}
async runKhanegiExpiry() {
try {
const plugins = await this.prisma.pluginSubscription.findMany({
where: {
settingKey: { not: null },
expiresAt: { not: null },
tenant: { product: ProductType.khanegi, isActive: true },
},
include: {
tenant: {
select: {
id: true,
nameFa: true,
domain: true,
product: true,
nodeServer: true,
remoteApiToken: true,
},
},
},
});
for (const plugin of plugins) {
if (!plugin.expiresAt || !plugin.settingKey) continue;
const daysLeft = iranDaysUntil(plugin.expiresAt);
const marks = this.readMarks(plugin.alertMarks, plugin.expiresAt);
if (daysLeft === 3 || daysLeft === 2 || daysLeft === 1) {
const key = String(daysLeft);
if (!marks[key]) {
await this.smsTenantAdmins(
plugin.tenant,
`افزونه «${plugin.name}»: ${daysLeft} روز تا پایان اشتراک (انقضا ${formatIranDate(plugin.expiresAt)}).`,
);
marks[key] = true;
await this.prisma.pluginSubscription.update({
where: { id: plugin.id },
data: { alertMarks: marks as Prisma.InputJsonValue },
});
}
}
if (daysLeft === 0 && !marks['0']) {
this.notify.notifyAdmins(
`سررسید افزونه: «${plugin.name}» روی «${plugin.tenant.nameFa}» (${plugin.tenant.domain}) امروز تمام می‌شود.`,
);
marks['0'] = true;
await this.prisma.pluginSubscription.update({
where: { id: plugin.id },
data: { alertMarks: marks as Prisma.InputJsonValue },
});
}
if (plugin.autoCutOnExpiry && daysLeft <= 0 && plugin.isActive) {
await this.prisma.pluginSubscription.update({
where: { id: plugin.id },
data: { isActive: false },
});
await this.applySettingSwitch(plugin.tenantId, plugin.settingKey, false);
}
}
} catch (error: any) {
this.logger.error(`Khanegi plugin expiry failed: ${error?.message || error}`);
}
}
private assertRemoteKhanegi(tenantId: string, product: string) {
if (product !== 'khanegi') {
throw new ForbiddenException('فروشگاه افزونه فقط برای خانگی است');
}
if (!this.runtime.getPluginStoreEnabled()) {
throw new ForbiddenException('فروشگاه افزونه خاموش است');
}
if (!tenantId) throw new ForbiddenException('نسخه نامعتبر است');
}
private assertPrices(prices: SaveCatalogItemDto['prices']) {
const seen = new Set<number>();
for (const price of prices) {
if (!PLUGIN_STORE_DURATIONS.includes(price.durationDays as any)) {
throw new BadRequestException('مدت نامعتبر است');
}
if (seen.has(price.durationDays)) {
throw new BadRequestException('مدت تکراری است');
}
seen.add(price.durationDays);
if (price.enabled) {
if (price.originalPriceRials == null || price.finalPriceRials == null) {
throw new BadRequestException('برای مدت روشن باید قیمت اصلی و نهایی زده شود');
}
if (price.originalPriceRials < 0 || price.finalPriceRials < 0) {
throw new BadRequestException('قیمت منفی مجاز نیست');
}
}
}
}
private toCatalogAdmin(item: CatalogWithPrices) {
const priceMap = new Map(item.prices.map((p) => [p.durationDays, p]));
return {
id: item.id,
settingKey: item.settingKey,
titleFa: item.titleFa,
description: item.description,
inStore: item.inStore,
prices: PLUGIN_STORE_DURATIONS.map((days) => {
const row = priceMap.get(days);
return {
durationDays: days,
enabled: row?.enabled ?? false,
originalPriceRials: row?.originalPriceRials ?? 0,
finalPriceRials: row?.finalPriceRials ?? 0,
};
}),
};
}
private async getCatalogAdmin(id: string) {
const item = await this.prisma.pluginCatalogItem.findUnique({
where: { id },
include: { prices: { orderBy: { durationDays: 'asc' } } },
});
if (!item) throw new NotFoundException('افزونه کاتالوگ یافت نشد');
return this.toCatalogAdmin(item);
}
private toRequestRow(row: any) {
return {
id: row.id,
tenantId: row.tenantId,
tenantName: row.tenant?.nameFa,
domain: row.tenant?.domain,
catalogItemId: row.catalogItemId,
pluginTitle: row.catalogItem?.titleFa,
settingKey: row.catalogItem?.settingKey,
durationDays: row.durationDays,
originalPriceRials: row.originalPriceRials,
finalPriceRials: row.finalPriceRials,
status: row.status,
autoCutOnExpiry: row.autoCutOnExpiry,
createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
decidedAt: row.decidedAt?.toISOString?.() ?? row.decidedAt ?? null,
};
}
private readMarks(raw: unknown, expiresAt: Date): Record<string, any> {
const iso = expiresAt.toISOString();
if (!raw || typeof raw !== 'object') return { _exp: iso };
const obj = raw as Record<string, any>;
if (obj._exp && obj._exp !== iso) return { _exp: iso };
return { ...obj, _exp: iso };
}
private async smsTenantAdmins(
tenant: {
id: string;
nodeServer: string;
remoteApiToken: string | null;
nameFa: string;
},
text: string,
) {
try {
const mobiles = await this.fetchTenantAdminMobiles(tenant);
if (!mobiles.length) {
this.logger.warn(`no admin mobiles for tenant ${tenant.id}`);
return;
}
for (const mobile of mobiles) {
await this.sms.send(mobile, text);
}
} catch (error: any) {
this.logger.warn(`tenant admin SMS failed ${tenant.id}: ${error?.message || error}`);
}
}
private async fetchTenantAdminMobiles(tenant: {
nodeServer: string;
remoteApiToken: string | null;
}): Promise<string[]> {
const base = String(tenant.nodeServer || '').replace(/\/+$/, '');
if (!base) return [];
const origin = base.replace(/\/api\/?$/, '');
const url = `${origin}/api/super/admin-mobiles`;
const secret = String(this.config.get('SMS_REMOTE_SHARED_SECRET') || '').trim();
const token = String(tenant.remoteApiToken || '').trim();
const bearer = secret || token;
if (!bearer) return [];
const { data } = await axios.get(url, {
headers: { Accept: 'application/json', Authorization: `Bearer ${bearer}` },
timeout: 8000,
validateStatus: () => true,
});
const list = Array.isArray(data?.mobiles) ? data.mobiles : [];
return list.map((m: unknown) => String(m || '').trim()).filter(Boolean);
}
}
+8 -4
View File
@@ -1,13 +1,17 @@
import { Module } from '@nestjs/common';
import { AuthModule } from '../auth/auth.module';
import { NotificationsModule } from '../notifications/notifications.module';
import { ProgramSettingsModule } from '../program-settings/program-settings.module';
import { PluginsController } from './plugins.controller';
import { PluginStoreController } from './plugin-store.controller';
import { RemotePluginsController } from './remote-plugins.controller';
import { PluginsService } from './plugins.service';
import { PluginStoreService } from './plugin-store.service';
@Module({
imports: [NotificationsModule, AuthModule],
controllers: [PluginsController],
providers: [PluginsService],
exports: [PluginsService],
imports: [NotificationsModule, AuthModule, ProgramSettingsModule],
controllers: [PluginsController, PluginStoreController, RemotePluginsController],
providers: [PluginsService, PluginStoreService],
exports: [PluginsService, PluginStoreService],
})
export class PluginsModule {}
+46 -3
View File
@@ -10,6 +10,7 @@ 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 { PluginStoreService } from './plugin-store.service';
import {
addIranPlanExpiry,
formatIranDate,
@@ -34,6 +35,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
private readonly prisma: PrismaService,
private readonly audit: AuditService,
private readonly notify: AdminNotifyService,
private readonly store: PluginStoreService,
) {}
onModuleInit() {
@@ -142,6 +144,14 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
},
});
const catalogKeys =
tenant.product === 'khanegi'
? await this.prisma.pluginCatalogItem.findMany({
select: { settingKey: true, titleFa: true },
orderBy: { titleFa: 'asc' },
})
: [];
return {
tenant: {
id: tenant.id,
@@ -157,6 +167,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
summary: this.toSummary(p),
charges: p.charges,
})),
catalogKeys,
planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({
months: m,
label: this.planLabel(m),
@@ -193,7 +204,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
}
async create(tenantId: string, dto: CreatePluginDto, userId?: string) {
await this.requireTenant(tenantId);
const tenant = await this.requireTenant(tenantId);
const name = dto.name.trim();
if (!name) throw new BadRequestException('نام افزونه لازم است');
@@ -208,6 +219,11 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
expiresAt = addIranPlanExpiry(chargedAt, planMonths);
}
const settingKey =
tenant.product === 'khanegi'
? dto.settingKey?.trim() || this.store.inferKey(name)
: null;
const plugin = await this.prisma.pluginSubscription.create({
data: {
tenantId,
@@ -219,6 +235,8 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
planMonths,
expiresAt,
alertDaysBefore: dto.alertDaysBefore ?? 3,
settingKey,
autoCutOnExpiry: tenant.product === 'khanegi' ? Boolean(dto.autoCutOnExpiry) : false,
},
});
@@ -245,11 +263,14 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue,
});
const tenant = await this.requireTenant(tenantId);
this.notify.notifyAdmins(
`سوپرادمین: افزونه «${plugin.name}» برای نسخه «${tenant.nameFa}» اضافه شد.`,
);
if (settingKey) {
await this.store.applySettingSwitch(tenantId, settingKey, true);
}
return this.getPlugin(plugin.id);
}
@@ -281,6 +302,15 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
expiresAt = addIranPlanExpiry(chargedAt, planMonths);
}
const tenant = await this.requireTenant(before.tenantId);
const nextName = dto.name !== undefined ? dto.name.trim() : before.name;
const settingKey =
tenant.product === 'khanegi'
? dto.settingKey !== undefined
? dto.settingKey?.trim() || null
: before.settingKey || this.store.inferKey(nextName)
: before.settingKey;
const plugin = await this.prisma.pluginSubscription.update({
where: { id: pluginId },
data: {
@@ -299,6 +329,11 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
alertDaysBefore: dto.alertDaysBefore,
}),
...(dto.isActive !== undefined && { isActive: dto.isActive }),
...(tenant.product === 'khanegi' && { settingKey }),
...(tenant.product === 'khanegi' &&
dto.autoCutOnExpiry !== undefined && {
autoCutOnExpiry: dto.autoCutOnExpiry,
}),
},
});
@@ -312,6 +347,11 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue,
});
if (tenant.product === 'khanegi' && settingKey) {
const on = plugin.isActive !== false;
await this.store.applySettingSwitch(plugin.tenantId, settingKey, on);
}
return this.getPlugin(plugin.id);
}
@@ -413,7 +453,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
tenant: { isActive: true },
},
include: {
tenant: { select: { nameFa: true, domain: true } },
tenant: { select: { nameFa: true, domain: true, product: true } },
},
});
@@ -421,6 +461,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
let sent = 0;
for (const plugin of plugins) {
if (plugin.tenant.product === 'khanegi' && plugin.settingKey) continue;
if (!plugin.expiresAt) continue;
const daysLeft = iranDaysUntil(plugin.expiresAt);
const threshold = plugin.alertDaysBefore ?? 3;
@@ -522,6 +563,8 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
description: plugin.description,
alertDaysBefore: plugin.alertDaysBefore,
lastAlertDate: plugin.lastAlertDate?.toISOString() ?? null,
settingKey: plugin.settingKey ?? null,
autoCutOnExpiry: plugin.autoCutOnExpiry ?? false,
isActive: plugin.isActive,
updatedAt: plugin.updatedAt.toISOString(),
};
+45
View File
@@ -0,0 +1,45 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { RemoteTenant } from '../auth/remote-tenant.decorator';
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import { CreatePurchaseRequestDto } from './dto/plugin-store.dto';
import { PluginStoreService } from './plugin-store.service';
type RemoteTenantInfo = {
id: string;
nameFa: string;
domain: string | null;
product: string;
isActive: boolean;
};
@Controller('remote/plugins')
@UseGuards(RemoteSmsAuthGuard)
export class RemotePluginsController {
constructor(private readonly store: PluginStoreService) {}
@Get('store')
storeFront(@RemoteTenant() tenant: RemoteTenantInfo) {
return this.store.remoteStore(tenant.id, tenant.product);
}
@Get('requests')
list(@RemoteTenant() tenant: RemoteTenantInfo) {
return this.store.remoteListRequests(tenant.id, tenant.product);
}
@Post('requests')
create(
@RemoteTenant() tenant: RemoteTenantInfo,
@Body() dto: CreatePurchaseRequestDto,
) {
return this.store.remoteCreateRequest(tenant.id, tenant.product, dto);
}
@Post('requests/:id/cancel')
cancel(
@RemoteTenant() tenant: RemoteTenantInfo,
@Param('id') id: string,
) {
return this.store.remoteCancelRequest(tenant.id, tenant.product, id);
}
}