From 1f776cf6e2aafeedcc296a30366786c8db47dc58 Mon Sep 17 00:00:00 2001 From: adonis Date: Mon, 7 Sep 2026 16:56:51 +0330 Subject: [PATCH] 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 --- prisma/schema.prisma | 66 ++ src/get-user/get-user.module.ts | 2 + src/get-user/get-user.service.ts | 12 +- .../runtime-settings.controller.ts | 13 + src/notifications/runtime-settings.service.ts | 16 + src/plugins/dto/plugin-store.dto.ts | 98 +++ src/plugins/dto/plugin.dto.ts | 18 + src/plugins/plugin-catalog.seed.ts | 114 +++ src/plugins/plugin-store.controller.ts | 64 ++ src/plugins/plugin-store.service.ts | 755 ++++++++++++++++++ src/plugins/plugins.module.ts | 12 +- src/plugins/plugins.service.ts | 49 +- src/plugins/remote-plugins.controller.ts | 45 ++ .../program-settings.service.ts | 85 +- src/tenants/tenants.service.ts | 9 +- src/uploads/uploads.util.ts | 42 + 16 files changed, 1386 insertions(+), 14 deletions(-) create mode 100644 src/plugins/dto/plugin-store.dto.ts create mode 100644 src/plugins/plugin-catalog.seed.ts create mode 100644 src/plugins/plugin-store.controller.ts create mode 100644 src/plugins/plugin-store.service.ts create mode 100644 src/plugins/remote-plugins.controller.ts diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 7737ca0..f379c12 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -87,6 +87,7 @@ model Tenant { /// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL) serverAccounts ServerAccount[] pluginSubscriptions PluginSubscription[] + pluginPurchaseRequests PluginPurchaseRequest[] supportSubscriptions SupportSubscription[] settings Setting[] @@ -311,6 +312,11 @@ model PluginSubscription { description String? alertDaysBefore Int @default(3) @map("alert_days_before") lastAlertDate DateTime? @map("last_alert_date") + /// Khanegi catalog key (jibit_enabled, …). Null = custom / hamkar. + settingKey String? @map("setting_key") @db.VarChar(50) + autoCutOnExpiry Boolean @default(false) @map("auto_cut_on_expiry") + /// Sent expiry SMS marks: { "expiresAtIso": { "3": true, "2": true, "1": true, "0": true } } + alertMarks Json? @map("alert_marks") isActive Boolean @default(true) @map("is_active") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -321,9 +327,69 @@ model PluginSubscription { @@index([tenantId]) @@index([expiresAt]) @@index([isActive]) + @@index([settingKey]) @@map("plugin_subscriptions") } +enum PluginPurchaseStatus { + pending + approved + rejected + cancelled +} + +model PluginCatalogItem { + id String @id @default(cuid()) + settingKey String @unique @map("setting_key") @db.VarChar(50) + titleFa String @map("title_fa") + description String? @db.Text + inStore Boolean @default(true) @map("in_store") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + prices PluginCatalogPrice[] + requests PluginPurchaseRequest[] + + @@map("plugin_catalog_items") +} + +model PluginCatalogPrice { + id String @id @default(cuid()) + catalogItemId String @map("catalog_item_id") + durationDays Int @map("duration_days") + enabled Boolean @default(false) + originalPriceRials Int @default(0) @map("original_price_rials") + finalPriceRials Int @default(0) @map("final_price_rials") + + catalogItem PluginCatalogItem @relation(fields: [catalogItemId], references: [id], onDelete: Cascade) + + @@unique([catalogItemId, durationDays]) + @@map("plugin_catalog_prices") +} + +model PluginPurchaseRequest { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + catalogItemId String @map("catalog_item_id") + durationDays Int @map("duration_days") + originalPriceRials Int @map("original_price_rials") + finalPriceRials Int @map("final_price_rials") + status PluginPurchaseStatus @default(pending) + autoCutOnExpiry Boolean? @map("auto_cut_on_expiry") + pluginSubscriptionId String? @map("plugin_subscription_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + decidedAt DateTime? @map("decided_at") + + tenant Tenant @relation(fields: [tenantId], references: [id], onDelete: Cascade) + catalogItem PluginCatalogItem @relation(fields: [catalogItemId], references: [id], onDelete: Restrict) + + @@index([tenantId, status]) + @@index([catalogItemId]) + @@index([createdAt]) + @@map("plugin_purchase_requests") +} + model PluginCharge { id String @id @default(cuid()) pluginId String @map("plugin_id") diff --git a/src/get-user/get-user.module.ts b/src/get-user/get-user.module.ts index 2aa722b..0dd01ea 100644 --- a/src/get-user/get-user.module.ts +++ b/src/get-user/get-user.module.ts @@ -1,8 +1,10 @@ import { Module } from '@nestjs/common'; +import { NotificationsModule } from '../notifications/notifications.module'; import { GetUserController } from './get-user.controller'; import { GetUserService } from './get-user.service'; @Module({ + imports: [NotificationsModule], controllers: [GetUserController], providers: [GetUserService], exports: [GetUserService], diff --git a/src/get-user/get-user.service.ts b/src/get-user/get-user.service.ts index 9259859..a1c64a6 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common'; import { iranDaysUntil } from '../common/iran-time.util'; import { PrismaService } from '../prisma/prisma.service'; import { applyKhanegiNavSample } from './khanegi-nav-sample'; +import { rewriteLegacyUploadUrl } from '../uploads/uploads.util'; +import { RuntimeSettingsService } from '../notifications/runtime-settings.service'; type UiSetting = { key: string; value: string }; @@ -23,7 +25,10 @@ type SubscriptionAlerts = { @Injectable() export class GetUserService { - constructor(private readonly prisma: PrismaService) {} + constructor( + private readonly prisma: PrismaService, + private readonly runtime: RuntimeSettingsService, + ) {} async resolveByHost(hostHeader?: string, domainQuery?: string) { const host = this.normalizeHost(domainQuery || hostHeader || ''); @@ -88,7 +93,7 @@ export class GetUserService { name: tenant.nameEn || tenant.slug, name_fa: tenant.nameFa, name_en: tenant.nameEn, - avatar: tenant.avatarUrl, + avatar: rewriteLegacyUploadUrl(tenant.avatarUrl), color, secondary_color: secondary, background_color: background, @@ -111,6 +116,7 @@ export class GetUserService { ui_settings: uiSettings.length ? uiSettings : undefined, /** Admin expiry modals — near/expired server, plugin, support */ subscription_alerts, + plugin_store_enabled: isKhanegi ? this.runtime.getPluginStoreEnabled() : false, }; return { @@ -265,7 +271,7 @@ export class GetUserService { } private toAbsoluteIconUrl(raw: unknown): string { - const value = String(raw || '').trim(); + const value = rewriteLegacyUploadUrl(raw); if (!value) return ''; if (/^(https?:\/\/|data:)/i.test(value)) return value; if (value.startsWith('//')) return `https:${value}`; diff --git a/src/notifications/runtime-settings.controller.ts b/src/notifications/runtime-settings.controller.ts index 3dfe885..078b145 100644 --- a/src/notifications/runtime-settings.controller.ts +++ b/src/notifications/runtime-settings.controller.ts @@ -22,4 +22,17 @@ export class RuntimeSettingsController { this.settings.setAdminSmsEnabled(dto.enabled); return this.settings.getPublic(); } + + @Get('plugin-store') + @RequirePermissions('settings:write') + getPluginStore() { + return this.settings.getPublic(); + } + + @Patch('plugin-store') + @RequirePermissions('settings:write') + setPluginStore(@Body() dto: UpdateAdminSmsDto) { + this.settings.setPluginStoreEnabled(dto.enabled); + return this.settings.getPublic(); + } } diff --git a/src/notifications/runtime-settings.service.ts b/src/notifications/runtime-settings.service.ts index 483232a..24bc1ee 100644 --- a/src/notifications/runtime-settings.service.ts +++ b/src/notifications/runtime-settings.service.ts @@ -5,10 +5,13 @@ import { join } from 'path'; type RuntimeSettings = { /** When false, SuperAdmin stops SMS alerts to managers */ adminSmsEnabled: boolean; + /** When false, khanegi admin plugin store menu is hidden */ + pluginStoreEnabled: boolean; }; const DEFAULTS: RuntimeSettings = { adminSmsEnabled: true, + pluginStoreEnabled: false, }; @Injectable() @@ -28,9 +31,21 @@ export class RuntimeSettingsService { return next; } + getPluginStoreEnabled(): boolean { + return this.read().pluginStoreEnabled === true; + } + + setPluginStoreEnabled(enabled: boolean) { + const next = { ...this.read(), pluginStoreEnabled: Boolean(enabled) }; + this.write(next); + this.logger.log(`Plugin store ${next.pluginStoreEnabled ? 'enabled' : 'disabled'}`); + return next; + } + getPublic() { return { adminSmsEnabled: this.getAdminSmsEnabled(), + pluginStoreEnabled: this.getPluginStoreEnabled(), }; } @@ -44,6 +59,7 @@ export class RuntimeSettingsService { const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial; this.cache = { adminSmsEnabled: raw.adminSmsEnabled !== false, + pluginStoreEnabled: raw.pluginStoreEnabled === true, }; return this.cache; } catch (error: any) { diff --git a/src/plugins/dto/plugin-store.dto.ts b/src/plugins/dto/plugin-store.dto.ts new file mode 100644 index 0000000..37ab702 --- /dev/null +++ b/src/plugins/dto/plugin-store.dto.ts @@ -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; +} diff --git a/src/plugins/dto/plugin.dto.ts b/src/plugins/dto/plugin.dto.ts index a3b6bcc..d26c56f 100644 --- a/src/plugins/dto/plugin.dto.ts +++ b/src/plugins/dto/plugin.dto.ts @@ -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 { diff --git a/src/plugins/plugin-catalog.seed.ts b/src/plugins/plugin-catalog.seed.ts new file mode 100644 index 0000000..6b3135e --- /dev/null +++ b/src/plugins/plugin-catalog.seed.ts @@ -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; +} diff --git a/src/plugins/plugin-store.controller.ts b/src/plugins/plugin-store.controller.ts new file mode 100644 index 0000000..a9a8b22 --- /dev/null +++ b/src/plugins/plugin-store.controller.ts @@ -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); + } +} diff --git a/src/plugins/plugin-store.service.ts b/src/plugins/plugin-store.service.ts new file mode 100644 index 0000000..128cfae --- /dev/null +++ b/src/plugins/plugin-store.service.ts @@ -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(); + 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 { + const iso = expiresAt.toISOString(); + if (!raw || typeof raw !== 'object') return { _exp: iso }; + const obj = raw as Record; + 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 { + 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); + } +} diff --git a/src/plugins/plugins.module.ts b/src/plugins/plugins.module.ts index 62cd914..9191a26 100644 --- a/src/plugins/plugins.module.ts +++ b/src/plugins/plugins.module.ts @@ -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 {} diff --git a/src/plugins/plugins.service.ts b/src/plugins/plugins.service.ts index 4acb4e7..74d539f 100644 --- a/src/plugins/plugins.service.ts +++ b/src/plugins/plugins.service.ts @@ -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(), }; diff --git a/src/plugins/remote-plugins.controller.ts b/src/plugins/remote-plugins.controller.ts new file mode 100644 index 0000000..bcaaa22 --- /dev/null +++ b/src/plugins/remote-plugins.controller.ts @@ -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); + } +} diff --git a/src/program-settings/program-settings.service.ts b/src/program-settings/program-settings.service.ts index 88a4a35..52195f5 100644 --- a/src/program-settings/program-settings.service.ts +++ b/src/program-settings/program-settings.service.ts @@ -57,6 +57,7 @@ type TemplateSeedRow = { @Injectable() export class ProgramSettingsService implements OnModuleInit { private readonly logger = new Logger(ProgramSettingsService.name); + private extraOwnedKeys = new Set(); constructor( private readonly prisma: PrismaService, @@ -76,6 +77,7 @@ export class ProgramSettingsService implements OnModuleInit { this.logger.log(`purged non-owned keys from setting_templates (templates=${purged.templates})`); } await this.seedEmptyKhanegiTenants(); + await this.refreshExtraOwnedKeys(); } catch (error: any) { this.logger.warn(`setting_templates seed skipped: ${error?.message || error}`); } @@ -573,16 +575,95 @@ export class ProgramSettingsService implements OnModuleInit { } } + async refreshExtraOwnedKeys() { + try { + const rows = await this.prisma.pluginCatalogItem.findMany({ + select: { settingKey: true }, + }); + this.extraOwnedKeys = new Set(rows.map((row) => row.settingKey)); + } catch { + // table may not exist yet before prisma db push + } + } + + async setOwnedSwitch(tenantId: string, key: string, on: boolean, userId?: string) { + await this.refreshExtraOwnedKeys(); + return this.updateItems( + tenantId, + [{ key, value: on ? 'true' : 'false', type: 'switch', is_active: 1 }], + userId, + ); + } + + async provisionOwnedSwitch(params: { + key: string; + titleFa: string; + description?: string; + }) { + const key = params.key.trim(); + const titleFa = params.titleFa.trim() || key; + const description = params.description || ''; + + await this.prisma.settingTemplate.upsert({ + where: { key }, + create: { + key, + titleFa, + description, + type: 'switch', + value: 'false', + canEdit: 0, + isActive: 1, + isExist: true, + }, + update: { + titleFa, + description, + type: 'switch', + canEdit: 0, + isExist: true, + deletedAt: null, + }, + }); + + const tenants = await this.prisma.tenant.findMany({ + where: { product: ProductType.khanegi }, + select: { id: true }, + }); + for (const tenant of tenants) { + const existing = await this.prisma.setting.findFirst({ + where: { tenantId: tenant.id, key, deletedAt: null }, + }); + if (existing) continue; + await this.prisma.setting.create({ + data: { + tenantId: tenant.id, + key, + titleFa, + description, + type: 'switch', + value: 'false', + canEdit: 0, + isActive: 1, + isExist: true, + }, + }); + } + + this.extraOwnedKeys.add(key); + this.replicaWatch.kick(); + } + /** فقط can_edit=0 نهایی deploy_settings_const — نه کل INSERTها */ private deployCatalogKeySet() { - return new Set(DEPLOY_SETTING_CATALOG_KEYS); + return new Set([...DEPLOY_SETTING_CATALOG_KEYS, ...this.extraOwnedKeys]); } private isSystemProgramSettingKey(key: string) { const k = String(key || '').trim(); if (!k || isRuntimeProgramSettingKey(k)) return false; if (DEPLOY_SETTING_EDITABLE_KEYS.has(k)) return false; - return DEPLOY_SETTING_CATALOG_KEYS.has(k); + return DEPLOY_SETTING_CATALOG_KEYS.has(k) || this.extraOwnedKeys.has(k); } /** diff --git a/src/tenants/tenants.service.ts b/src/tenants/tenants.service.ts index 9763f2b..5429aef 100644 --- a/src/tenants/tenants.service.ts +++ b/src/tenants/tenants.service.ts @@ -11,6 +11,7 @@ import { ProgramSettingsService } from '../program-settings/program-settings.ser import { ReplicaWatchService } from '../replica-watch/replica-watch.service'; import { CreateTenantDto } from './dto/create-tenant.dto'; import { UpdateTenantDto } from './dto/update-tenant.dto'; +import { rewriteLegacyUploadUrl } from '../uploads/uploads.util'; @Injectable() export class TenantsService { @@ -55,6 +56,7 @@ export class TenantsService { return rows.map(({ appUiConfig, _count, ...rest }: any) => ({ ...rest, + avatarUrl: rewriteLegacyUploadUrl(rest.avatarUrl) || rest.avatarUrl, hasAppUiConfig: Boolean(appUiConfig), hasProgramSettings: (_count?.settings ?? 0) > 0, })); @@ -87,7 +89,7 @@ export class TenantsService { nameEn: dto.nameEn, domain, domains: aliases as unknown as Prisma.InputJsonValue, - avatarUrl: dto.avatarUrl, + avatarUrl: rewriteLegacyUploadUrl(dto.avatarUrl) || dto.avatarUrl, primaryColor: dto.primaryColor, secondaryColor: dto.secondaryColor, backgroundColor: dto.backgroundColor, @@ -211,7 +213,9 @@ export class TenantsService { ...(nextAliases !== undefined && { domains: nextAliases as unknown as Prisma.InputJsonValue, }), - ...(dto.avatarUrl !== undefined && { avatarUrl: dto.avatarUrl }), + ...(dto.avatarUrl !== undefined && { + avatarUrl: rewriteLegacyUploadUrl(dto.avatarUrl) || dto.avatarUrl, + }), ...(dto.primaryColor !== undefined && { primaryColor: dto.primaryColor, }), @@ -307,6 +311,7 @@ export class TenantsService { const { remoteApiToken, ...rest } = tenant; return { ...rest, + avatarUrl: rewriteLegacyUploadUrl(rest.avatarUrl) || rest.avatarUrl, domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)), hasRemoteApiToken: Boolean(remoteApiToken), }; diff --git a/src/uploads/uploads.util.ts b/src/uploads/uploads.util.ts index dd9cea5..5409907 100644 --- a/src/uploads/uploads.util.ts +++ b/src/uploads/uploads.util.ts @@ -18,3 +18,45 @@ export function publicApiBase(config: { const port = config.get('PORT', '3001') ?? '3001'; return `http://localhost:${port}`; } + +export function publicApiBaseFromEnv(): string { + const fromEnv = String(process.env.PUBLIC_API_URL || process.env.APP_URL || '') + .trim() + .replace(/\/$/, ''); + return fromEnv || 'https://adm.montzar.ir'; +} + +const LEGACY_UPLOAD_HOSTS = new Set([ + 'nerkh.gold', + 'www.nerkh.gold', + 'tablo.talasevin.ir', + 'ah.aygingold.ir', + 'abshode-service.montgit.ir', +]); + +/** nerkh.gold/uploads/... → PUBLIC_API_URL/uploads/... (فایل روی سوپرادمین است) */ +export function rewriteLegacyUploadUrl( + raw: unknown, + publicBase = publicApiBaseFromEnv(), +): string { + const value = String(raw || '').trim(); + if (!value) return ''; + const base = String(publicBase || '').replace(/\/$/, ''); + if (!base) return value; + + if (value.startsWith('/uploads/')) { + return `${base}${value}`; + } + + if (!/^https?:\/\//i.test(value)) return value; + + try { + const parsed = new URL(value); + const host = parsed.hostname.toLowerCase(); + if (!LEGACY_UPLOAD_HOSTS.has(host)) return value; + if (!/\/uploads\//i.test(parsed.pathname)) return value; + return `${base}${parsed.pathname}`; + } catch { + return value; + } +}