diff --git a/src/get-user/get-user.module.ts b/src/get-user/get-user.module.ts index 0dd01ea..2aa722b 100644 --- a/src/get-user/get-user.module.ts +++ b/src/get-user/get-user.module.ts @@ -1,10 +1,8 @@ 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 a1c64a6..77ce18e 100644 --- a/src/get-user/get-user.service.ts +++ b/src/get-user/get-user.service.ts @@ -3,7 +3,7 @@ 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'; +import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys'; type UiSetting = { key: string; value: string }; @@ -25,10 +25,7 @@ type SubscriptionAlerts = { @Injectable() export class GetUserService { - constructor( - private readonly prisma: PrismaService, - private readonly runtime: RuntimeSettingsService, - ) {} + constructor(private readonly prisma: PrismaService) {} async resolveByHost(hostHeader?: string, domainQuery?: string) { const host = this.normalizeHost(domainQuery || hostHeader || ''); @@ -116,7 +113,9 @@ 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, + plugin_store_enabled: isKhanegi + ? await this.isPluginStoreEnabled(tenant.id) + : false, }; return { @@ -125,6 +124,18 @@ export class GetUserService { }; } + private async isPluginStoreEnabled(tenantId: string): Promise { + try { + const row = await this.prisma.setting.findUnique({ + where: { tenantId_key: { tenantId, key: PLUGIN_STORE_SETTING_KEY } }, + select: { value: true }, + }); + return row?.value === 'true'; + } catch { + return false; + } + } + private async buildSubscriptionAlerts(tenantId: string): Promise { const [servers, plugins, supports] = await Promise.all([ this.prisma.serverAccount.findMany({ diff --git a/src/notifications/runtime-settings.controller.ts b/src/notifications/runtime-settings.controller.ts index 078b145..3dfe885 100644 --- a/src/notifications/runtime-settings.controller.ts +++ b/src/notifications/runtime-settings.controller.ts @@ -22,17 +22,4 @@ 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 24bc1ee..232db67 100644 --- a/src/notifications/runtime-settings.service.ts +++ b/src/notifications/runtime-settings.service.ts @@ -5,13 +5,10 @@ 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() @@ -31,22 +28,13 @@ 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(), - }; + try { + return { adminSmsEnabled: this.getAdminSmsEnabled() }; + } catch (error: any) { + this.logger.warn(`getPublic failed: ${error?.message || error}`); + return { adminSmsEnabled: true }; + } } private read(): RuntimeSettings { @@ -59,7 +47,6 @@ 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.dto.ts b/src/plugins/dto/plugin.dto.ts index d26c56f..04e4094 100644 --- a/src/plugins/dto/plugin.dto.ts +++ b/src/plugins/dto/plugin.dto.ts @@ -137,3 +137,8 @@ export class RenewPluginDto { @MaxLength(500) note?: string; } + +export class UpdatePluginStoreEnabledDto { + @IsBoolean() + enabled!: boolean; +} diff --git a/src/plugins/plugin-store.service.ts b/src/plugins/plugin-store.service.ts index 128cfae..1adf442 100644 --- a/src/plugins/plugin-store.service.ts +++ b/src/plugins/plugin-store.service.ts @@ -21,8 +21,8 @@ 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 { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys'; import { addIranMonths, addIranPlanExpiry, @@ -55,7 +55,6 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { private readonly audit: AuditService, private readonly notify: AdminNotifyService, private readonly sms: CentralSmsService, - private readonly runtime: RuntimeSettingsService, private readonly programSettings: ProgramSettingsService, private readonly config: ConfigService, ) {} @@ -135,6 +134,69 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { return inferSettingKeyFromName(name); } + async isStoreEnabled(tenantId: string): Promise { + if (!tenantId) return false; + try { + const row = await this.prisma.setting.findUnique({ + where: { tenantId_key: { tenantId, key: PLUGIN_STORE_SETTING_KEY } }, + select: { value: true }, + }); + return row?.value === 'true'; + } catch (error: any) { + this.logger.warn(`isStoreEnabled ${tenantId}: ${error?.message || error}`); + return false; + } + } + + async storeEnabledMap(tenantIds: string[]): Promise> { + const map = new Map(); + if (!tenantIds.length) return map; + try { + const rows = await this.prisma.setting.findMany({ + where: { key: PLUGIN_STORE_SETTING_KEY, tenantId: { in: tenantIds } }, + select: { tenantId: true, value: true }, + }); + for (const row of rows) { + map.set(row.tenantId, row.value === 'true'); + } + } catch (error: any) { + this.logger.warn(`storeEnabledMap: ${error?.message || error}`); + } + return map; + } + + async setStoreEnabled(tenantId: string, enabled: boolean, userId?: string) { + const tenant = await this.prisma.tenant.findUnique({ where: { id: tenantId } }); + if (!tenant) throw new NotFoundException('نسخه یافت نشد'); + if (tenant.product !== ProductType.khanegi) { + throw new BadRequestException('فروشگاه افزونه فقط برای خانگی است'); + } + const value = enabled ? 'true' : 'false'; + await this.prisma.setting.upsert({ + where: { tenantId_key: { tenantId, key: PLUGIN_STORE_SETTING_KEY } }, + create: { + tenantId, + key: PLUGIN_STORE_SETTING_KEY, + titleFa: 'فروشگاه افزونه', + value, + type: 'boolean', + canEdit: 0, + isExist: true, + isActive: 1, + }, + update: { value, deletedAt: null }, + }); + await this.audit.log({ + action: 'plugin_store.toggle', + entityType: 'tenant', + entityId: tenantId, + tenantId, + userId, + after: { plugin_store_enabled: enabled } as Prisma.InputJsonValue, + }); + return { pluginStoreEnabled: enabled }; + } + 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; @@ -148,12 +210,17 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { } 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)); + try { + 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)); + } catch (error: any) { + this.logger.warn(`listCatalogAdmin skipped: ${error?.message || error}`); + return []; + } } async saveCatalogItem(id: string, dto: SaveCatalogItemDto, userId?: string) { @@ -254,23 +321,28 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { } 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 }, + try { + 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 } }, }, - catalogItem: { select: { id: true, titleFa: true, settingKey: true } }, - }, - }); - return rows.map((row) => this.toRequestRow(row)); + }); + return rows.map((row) => this.toRequestRow(row)); + } catch (error: any) { + this.logger.warn(`listRequestsAdmin skipped: ${error?.message || error}`); + return []; + } } async approveRequest(id: string, dto: ApprovePurchaseDto, userId?: string) { @@ -396,12 +468,8 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { return { ok: true }; } - remoteStoreEnabled() { - return this.runtime.getPluginStoreEnabled(); - } - async remoteStore(tenantId: string, product: string) { - this.assertRemoteKhanegi(tenantId, product); + await this.assertRemoteKhanegi(tenantId, product); const items = await this.prisma.pluginCatalogItem.findMany({ where: { inStore: true }, orderBy: { titleFa: 'asc' }, @@ -462,7 +530,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { } async remoteListRequests(tenantId: string, product: string) { - this.assertRemoteKhanegi(tenantId, product); + await this.assertRemoteKhanegi(tenantId, product); const rows = await this.prisma.pluginPurchaseRequest.findMany({ where: { tenantId }, orderBy: { createdAt: 'desc' }, @@ -479,7 +547,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { product: string, dto: CreatePurchaseRequestDto, ) { - this.assertRemoteKhanegi(tenantId, product); + await this.assertRemoteKhanegi(tenantId, product); const item = await this.prisma.pluginCatalogItem.findUnique({ where: { id: dto.catalogItemId }, include: { prices: true }, @@ -541,7 +609,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { } async remoteCancelRequest(tenantId: string, product: string, requestId: string) { - this.assertRemoteKhanegi(tenantId, product); + await this.assertRemoteKhanegi(tenantId, product); const req = await this.prisma.pluginPurchaseRequest.findFirst({ where: { id: requestId, tenantId }, }); @@ -622,14 +690,14 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy { } } - private assertRemoteKhanegi(tenantId: string, product: string) { + private async assertRemoteKhanegi(tenantId: string, product: string) { if (product !== 'khanegi') { throw new ForbiddenException('فروشگاه افزونه فقط برای خانگی است'); } - if (!this.runtime.getPluginStoreEnabled()) { - throw new ForbiddenException('فروشگاه افزونه خاموش است'); - } if (!tenantId) throw new ForbiddenException('نسخه نامعتبر است'); + if (!(await this.isStoreEnabled(tenantId))) { + throw new ForbiddenException('فروشگاه افزونه برای این نسخه خاموش است'); + } } private assertPrices(prices: SaveCatalogItemDto['prices']) { diff --git a/src/plugins/plugins.controller.ts b/src/plugins/plugins.controller.ts index e3e521f..f0a42ad 100644 --- a/src/plugins/plugins.controller.ts +++ b/src/plugins/plugins.controller.ts @@ -17,6 +17,7 @@ import { CreatePluginDto, RenewPluginDto, UpdatePluginDto, + UpdatePluginStoreEnabledDto, } from './dto/plugin.dto'; import { PluginsService } from './plugins.service'; @@ -37,6 +38,16 @@ export class PluginsController { return this.pluginsService.getExpiringSoon(Number(limit) || 5); } + @Patch('tenants/:tenantId/store-enabled') + @RequirePermissions('plugins:write') + setStoreEnabled( + @Param('tenantId') tenantId: string, + @Body() dto: UpdatePluginStoreEnabledDto, + @CurrentUser() user: { id: string }, + ) { + return this.pluginsService.setStoreEnabled(tenantId, dto.enabled, user.id); + } + @Get('tenants/:tenantId') @RequirePermissions('plugins:read') getTenantPlugins(@Param('tenantId') tenantId: string) { diff --git a/src/plugins/plugins.service.ts b/src/plugins/plugins.service.ts index 74d539f..4bcea0d 100644 --- a/src/plugins/plugins.service.ts +++ b/src/plugins/plugins.service.ts @@ -53,19 +53,11 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { 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' }, - }, - }, - }); + const rows = await this.loadTenantRows(); + const flags = await this.store.storeEnabledMap(rows.map((row) => row.id)); return rows.map((tenant) => { - const plugins = tenant.pluginSubscriptions.map((p) => this.toSummary(p)); + const plugins = (tenant.pluginSubscriptions || []).map((p) => this.toSummary(p)); const worst = this.worstLevel(plugins.map((p) => p.expiryLevel)); return { id: tenant.id, @@ -75,6 +67,8 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, + pluginStoreEnabled: + tenant.product === 'khanegi' && flags.get(tenant.id) === true, pluginCount: plugins.length, plugins, summary: { @@ -96,61 +90,82 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { } 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, + try { + const plugins = await this.prisma.pluginSubscription.findMany({ + where: { + isActive: true, + expiresAt: { not: null }, + tenant: { isActive: true }, + }, + select: { + id: true, + tenantId: true, + name: true, + specs: true, + priceRials: true, + chargedAt: true, + planMonths: true, + expiresAt: true, + description: true, + alertDaysBefore: true, + lastAlertDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + 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); + return plugins + .map((p) => ({ + ...this.toSummary(p as PluginSubscription), + 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); + } catch (error: any) { + this.logger.error(`getExpiringSoon failed: ${error?.message || error}`); + return []; + } + } + + async setStoreEnabled(tenantId: string, enabled: boolean, userId?: string) { + return this.store.setStoreEnabled(tenantId, enabled, userId); } 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 }, - }, - }); + const plugins = await this.loadTenantPlugins(tenantId); - const catalogKeys = - tenant.product === 'khanegi' - ? await this.prisma.pluginCatalogItem.findMany({ - select: { settingKey: true, titleFa: true }, - orderBy: { titleFa: 'asc' }, - }) - : []; + let catalogKeys: { settingKey: string; titleFa: string }[] = []; + if (tenant.product === 'khanegi') { + try { + catalogKeys = await this.prisma.pluginCatalogItem.findMany({ + select: { settingKey: true, titleFa: true }, + orderBy: { titleFa: 'asc' }, + }); + } catch (error: any) { + this.logger.warn(`catalogKeys skipped: ${error?.message || error}`); + } + } return { tenant: { @@ -161,10 +176,12 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { nameEn: tenant.nameEn, domain: tenant.domain, avatarUrl: tenant.avatarUrl, + pluginStoreEnabled: + tenant.product === 'khanegi' && (await this.store.isStoreEnabled(tenantId)), }, plugins: plugins.map((p) => ({ - ...this.publicPlugin(p), - summary: this.toSummary(p), + ...this.publicPlugin(p as PluginSubscription), + summary: this.toSummary(p as PluginSubscription), charges: p.charges, })), catalogKeys, @@ -590,4 +607,82 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy { if (!tenant) throw new NotFoundException('نسخه یافت نشد'); return tenant; } + + private async loadTenantPlugins(tenantId: string) { + const orderBy = [{ isActive: 'desc' as const }, { name: 'asc' as const }]; + const charges = { orderBy: { createdAt: 'desc' as const }, take: 10 }; + try { + return await this.prisma.pluginSubscription.findMany({ + where: { tenantId }, + orderBy, + include: { charges }, + }); + } catch (error: any) { + this.logger.warn(`loadTenantPlugins fallback: ${error?.message || error}`); + try { + return await this.prisma.pluginSubscription.findMany({ + where: { tenantId }, + orderBy, + select: { + id: true, + tenantId: true, + name: true, + specs: true, + priceRials: true, + chargedAt: true, + planMonths: true, + expiresAt: true, + description: true, + alertDaysBefore: true, + lastAlertDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + charges, + }, + }); + } catch (inner: any) { + this.logger.error(`loadTenantPlugins failed: ${inner?.message || inner}`); + return []; + } + } + } + + private async loadTenantRows() { + try { + return await this.prisma.tenant.findMany({ + where: { isActive: true }, + orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], + include: { + pluginSubscriptions: { + where: { isActive: true }, + orderBy: { name: 'asc' }, + select: { + id: true, + tenantId: true, + name: true, + specs: true, + priceRials: true, + chargedAt: true, + planMonths: true, + expiresAt: true, + description: true, + alertDaysBefore: true, + lastAlertDate: true, + isActive: true, + createdAt: true, + updatedAt: true, + }, + }, + }, + }); + } catch (error: any) { + this.logger.error(`listTenants plugins skipped: ${error?.message || error}`); + const rows = await this.prisma.tenant.findMany({ + where: { isActive: true }, + orderBy: [{ product: 'asc' }, { nameFa: 'asc' }], + }); + return rows.map((tenant) => ({ ...tenant, pluginSubscriptions: [] })); + } + } } diff --git a/src/program-settings/runtime-setting-keys.ts b/src/program-settings/runtime-setting-keys.ts index b7f20cd..69a61e5 100644 --- a/src/program-settings/runtime-setting-keys.ts +++ b/src/program-settings/runtime-setting-keys.ts @@ -1,4 +1,6 @@ /** کلیدهای runtime / لاگ — مال سوپرادمین نیستند؛ در اپ خانگی توسط cron پر می‌شوند. */ +export const PLUGIN_STORE_SETTING_KEY = 'plugin_store_enabled'; + export const RUNTIME_PROGRAM_SETTING_KEYS = new Set([ 'montzar_last_product_log', 'montzar_order_response', @@ -13,6 +15,7 @@ export const RUNTIME_PROGRAM_SETTING_KEYS = new Set([ 'down_price', 'price_extreme_date', 'do_send_liman', + PLUGIN_STORE_SETTING_KEY, ]); export function isRuntimeProgramSettingKey(keyRaw: string): boolean {