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:
@@ -87,6 +87,7 @@ model Tenant {
|
|||||||
/// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL)
|
/// 0..N hosting/billing servers per sold version (not the same as nodeServer API URL)
|
||||||
serverAccounts ServerAccount[]
|
serverAccounts ServerAccount[]
|
||||||
pluginSubscriptions PluginSubscription[]
|
pluginSubscriptions PluginSubscription[]
|
||||||
|
pluginPurchaseRequests PluginPurchaseRequest[]
|
||||||
supportSubscriptions SupportSubscription[]
|
supportSubscriptions SupportSubscription[]
|
||||||
settings Setting[]
|
settings Setting[]
|
||||||
|
|
||||||
@@ -311,6 +312,11 @@ model PluginSubscription {
|
|||||||
description String?
|
description String?
|
||||||
alertDaysBefore Int @default(3) @map("alert_days_before")
|
alertDaysBefore Int @default(3) @map("alert_days_before")
|
||||||
lastAlertDate DateTime? @map("last_alert_date")
|
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")
|
isActive Boolean @default(true) @map("is_active")
|
||||||
createdAt DateTime @default(now()) @map("created_at")
|
createdAt DateTime @default(now()) @map("created_at")
|
||||||
updatedAt DateTime @updatedAt @map("updated_at")
|
updatedAt DateTime @updatedAt @map("updated_at")
|
||||||
@@ -321,9 +327,69 @@ model PluginSubscription {
|
|||||||
@@index([tenantId])
|
@@index([tenantId])
|
||||||
@@index([expiresAt])
|
@@index([expiresAt])
|
||||||
@@index([isActive])
|
@@index([isActive])
|
||||||
|
@@index([settingKey])
|
||||||
@@map("plugin_subscriptions")
|
@@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 {
|
model PluginCharge {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
pluginId String @map("plugin_id")
|
pluginId String @map("plugin_id")
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
import { GetUserController } from './get-user.controller';
|
import { GetUserController } from './get-user.controller';
|
||||||
import { GetUserService } from './get-user.service';
|
import { GetUserService } from './get-user.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
|
imports: [NotificationsModule],
|
||||||
controllers: [GetUserController],
|
controllers: [GetUserController],
|
||||||
providers: [GetUserService],
|
providers: [GetUserService],
|
||||||
exports: [GetUserService],
|
exports: [GetUserService],
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|||||||
import { iranDaysUntil } from '../common/iran-time.util';
|
import { iranDaysUntil } from '../common/iran-time.util';
|
||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { applyKhanegiNavSample } from './khanegi-nav-sample';
|
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 };
|
type UiSetting = { key: string; value: string };
|
||||||
|
|
||||||
@@ -23,7 +25,10 @@ type SubscriptionAlerts = {
|
|||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class GetUserService {
|
export class GetUserService {
|
||||||
constructor(private readonly prisma: PrismaService) {}
|
constructor(
|
||||||
|
private readonly prisma: PrismaService,
|
||||||
|
private readonly runtime: RuntimeSettingsService,
|
||||||
|
) {}
|
||||||
|
|
||||||
async resolveByHost(hostHeader?: string, domainQuery?: string) {
|
async resolveByHost(hostHeader?: string, domainQuery?: string) {
|
||||||
const host = this.normalizeHost(domainQuery || hostHeader || '');
|
const host = this.normalizeHost(domainQuery || hostHeader || '');
|
||||||
@@ -88,7 +93,7 @@ export class GetUserService {
|
|||||||
name: tenant.nameEn || tenant.slug,
|
name: tenant.nameEn || tenant.slug,
|
||||||
name_fa: tenant.nameFa,
|
name_fa: tenant.nameFa,
|
||||||
name_en: tenant.nameEn,
|
name_en: tenant.nameEn,
|
||||||
avatar: tenant.avatarUrl,
|
avatar: rewriteLegacyUploadUrl(tenant.avatarUrl),
|
||||||
color,
|
color,
|
||||||
secondary_color: secondary,
|
secondary_color: secondary,
|
||||||
background_color: background,
|
background_color: background,
|
||||||
@@ -111,6 +116,7 @@ export class GetUserService {
|
|||||||
ui_settings: uiSettings.length ? uiSettings : undefined,
|
ui_settings: uiSettings.length ? uiSettings : undefined,
|
||||||
/** Admin expiry modals — near/expired server, plugin, support */
|
/** Admin expiry modals — near/expired server, plugin, support */
|
||||||
subscription_alerts,
|
subscription_alerts,
|
||||||
|
plugin_store_enabled: isKhanegi ? this.runtime.getPluginStoreEnabled() : false,
|
||||||
};
|
};
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -265,7 +271,7 @@ export class GetUserService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private toAbsoluteIconUrl(raw: unknown): string {
|
private toAbsoluteIconUrl(raw: unknown): string {
|
||||||
const value = String(raw || '').trim();
|
const value = rewriteLegacyUploadUrl(raw);
|
||||||
if (!value) return '';
|
if (!value) return '';
|
||||||
if (/^(https?:\/\/|data:)/i.test(value)) return value;
|
if (/^(https?:\/\/|data:)/i.test(value)) return value;
|
||||||
if (value.startsWith('//')) return `https:${value}`;
|
if (value.startsWith('//')) return `https:${value}`;
|
||||||
|
|||||||
@@ -22,4 +22,17 @@ export class RuntimeSettingsController {
|
|||||||
this.settings.setAdminSmsEnabled(dto.enabled);
|
this.settings.setAdminSmsEnabled(dto.enabled);
|
||||||
return this.settings.getPublic();
|
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();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,13 @@ import { join } from 'path';
|
|||||||
type RuntimeSettings = {
|
type RuntimeSettings = {
|
||||||
/** When false, SuperAdmin stops SMS alerts to managers */
|
/** When false, SuperAdmin stops SMS alerts to managers */
|
||||||
adminSmsEnabled: boolean;
|
adminSmsEnabled: boolean;
|
||||||
|
/** When false, khanegi admin plugin store menu is hidden */
|
||||||
|
pluginStoreEnabled: boolean;
|
||||||
};
|
};
|
||||||
|
|
||||||
const DEFAULTS: RuntimeSettings = {
|
const DEFAULTS: RuntimeSettings = {
|
||||||
adminSmsEnabled: true,
|
adminSmsEnabled: true,
|
||||||
|
pluginStoreEnabled: false,
|
||||||
};
|
};
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
@@ -28,9 +31,21 @@ export class RuntimeSettingsService {
|
|||||||
return next;
|
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() {
|
getPublic() {
|
||||||
return {
|
return {
|
||||||
adminSmsEnabled: this.getAdminSmsEnabled(),
|
adminSmsEnabled: this.getAdminSmsEnabled(),
|
||||||
|
pluginStoreEnabled: this.getPluginStoreEnabled(),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -44,6 +59,7 @@ export class RuntimeSettingsService {
|
|||||||
const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial<RuntimeSettings>;
|
const raw = JSON.parse(readFileSync(this.filePath, 'utf8')) as Partial<RuntimeSettings>;
|
||||||
this.cache = {
|
this.cache = {
|
||||||
adminSmsEnabled: raw.adminSmsEnabled !== false,
|
adminSmsEnabled: raw.adminSmsEnabled !== false,
|
||||||
|
pluginStoreEnabled: raw.pluginStoreEnabled === true,
|
||||||
};
|
};
|
||||||
return this.cache;
|
return this.cache;
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -52,6 +52,15 @@ export class CreatePluginDto {
|
|||||||
@Min(1)
|
@Min(1)
|
||||||
@Max(30)
|
@Max(30)
|
||||||
alertDaysBefore?: number;
|
alertDaysBefore?: number;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
settingKey?: string;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autoCutOnExpiry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class UpdatePluginDto {
|
export class UpdatePluginDto {
|
||||||
@@ -97,6 +106,15 @@ export class UpdatePluginDto {
|
|||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsBoolean()
|
@IsBoolean()
|
||||||
isActive?: boolean;
|
isActive?: boolean;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsString()
|
||||||
|
@MaxLength(50)
|
||||||
|
settingKey?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsBoolean()
|
||||||
|
autoCutOnExpiry?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class RenewPluginDto {
|
export class RenewPluginDto {
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,17 @@
|
|||||||
import { Module } from '@nestjs/common';
|
import { Module } from '@nestjs/common';
|
||||||
import { AuthModule } from '../auth/auth.module';
|
import { AuthModule } from '../auth/auth.module';
|
||||||
import { NotificationsModule } from '../notifications/notifications.module';
|
import { NotificationsModule } from '../notifications/notifications.module';
|
||||||
|
import { ProgramSettingsModule } from '../program-settings/program-settings.module';
|
||||||
import { PluginsController } from './plugins.controller';
|
import { PluginsController } from './plugins.controller';
|
||||||
|
import { PluginStoreController } from './plugin-store.controller';
|
||||||
|
import { RemotePluginsController } from './remote-plugins.controller';
|
||||||
import { PluginsService } from './plugins.service';
|
import { PluginsService } from './plugins.service';
|
||||||
|
import { PluginStoreService } from './plugin-store.service';
|
||||||
|
|
||||||
@Module({
|
@Module({
|
||||||
imports: [NotificationsModule, AuthModule],
|
imports: [NotificationsModule, AuthModule, ProgramSettingsModule],
|
||||||
controllers: [PluginsController],
|
controllers: [PluginsController, PluginStoreController, RemotePluginsController],
|
||||||
providers: [PluginsService],
|
providers: [PluginsService, PluginStoreService],
|
||||||
exports: [PluginsService],
|
exports: [PluginsService, PluginStoreService],
|
||||||
})
|
})
|
||||||
export class PluginsModule {}
|
export class PluginsModule {}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import { PluginSubscription, Prisma } from '@prisma/client';
|
|||||||
import { PrismaService } from '../prisma/prisma.service';
|
import { PrismaService } from '../prisma/prisma.service';
|
||||||
import { AuditService } from '../audit/audit.service';
|
import { AuditService } from '../audit/audit.service';
|
||||||
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
import { AdminNotifyService } from '../notifications/admin-notify.service';
|
||||||
|
import { PluginStoreService } from './plugin-store.service';
|
||||||
import {
|
import {
|
||||||
addIranPlanExpiry,
|
addIranPlanExpiry,
|
||||||
formatIranDate,
|
formatIranDate,
|
||||||
@@ -34,6 +35,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
private readonly prisma: PrismaService,
|
private readonly prisma: PrismaService,
|
||||||
private readonly audit: AuditService,
|
private readonly audit: AuditService,
|
||||||
private readonly notify: AdminNotifyService,
|
private readonly notify: AdminNotifyService,
|
||||||
|
private readonly store: PluginStoreService,
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
onModuleInit() {
|
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 {
|
return {
|
||||||
tenant: {
|
tenant: {
|
||||||
id: tenant.id,
|
id: tenant.id,
|
||||||
@@ -157,6 +167,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
summary: this.toSummary(p),
|
summary: this.toSummary(p),
|
||||||
charges: p.charges,
|
charges: p.charges,
|
||||||
})),
|
})),
|
||||||
|
catalogKeys,
|
||||||
planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({
|
planOptions: PLUGIN_PLAN_MONTHS.map((m) => ({
|
||||||
months: m,
|
months: m,
|
||||||
label: this.planLabel(m),
|
label: this.planLabel(m),
|
||||||
@@ -193,7 +204,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async create(tenantId: string, dto: CreatePluginDto, userId?: string) {
|
async create(tenantId: string, dto: CreatePluginDto, userId?: string) {
|
||||||
await this.requireTenant(tenantId);
|
const tenant = await this.requireTenant(tenantId);
|
||||||
const name = dto.name.trim();
|
const name = dto.name.trim();
|
||||||
if (!name) throw new BadRequestException('نام افزونه لازم است');
|
if (!name) throw new BadRequestException('نام افزونه لازم است');
|
||||||
|
|
||||||
@@ -208,6 +219,11 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
expiresAt = addIranPlanExpiry(chargedAt, planMonths);
|
expiresAt = addIranPlanExpiry(chargedAt, planMonths);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const settingKey =
|
||||||
|
tenant.product === 'khanegi'
|
||||||
|
? dto.settingKey?.trim() || this.store.inferKey(name)
|
||||||
|
: null;
|
||||||
|
|
||||||
const plugin = await this.prisma.pluginSubscription.create({
|
const plugin = await this.prisma.pluginSubscription.create({
|
||||||
data: {
|
data: {
|
||||||
tenantId,
|
tenantId,
|
||||||
@@ -219,6 +235,8 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
planMonths,
|
planMonths,
|
||||||
expiresAt,
|
expiresAt,
|
||||||
alertDaysBefore: dto.alertDaysBefore ?? 3,
|
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,
|
after: this.publicPlugin(plugin) as unknown as Prisma.InputJsonValue,
|
||||||
});
|
});
|
||||||
|
|
||||||
const tenant = await this.requireTenant(tenantId);
|
|
||||||
this.notify.notifyAdmins(
|
this.notify.notifyAdmins(
|
||||||
`سوپرادمین: افزونه «${plugin.name}» برای نسخه «${tenant.nameFa}» اضافه شد.`,
|
`سوپرادمین: افزونه «${plugin.name}» برای نسخه «${tenant.nameFa}» اضافه شد.`,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (settingKey) {
|
||||||
|
await this.store.applySettingSwitch(tenantId, settingKey, true);
|
||||||
|
}
|
||||||
|
|
||||||
return this.getPlugin(plugin.id);
|
return this.getPlugin(plugin.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -281,6 +302,15 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
expiresAt = addIranPlanExpiry(chargedAt, planMonths);
|
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({
|
const plugin = await this.prisma.pluginSubscription.update({
|
||||||
where: { id: pluginId },
|
where: { id: pluginId },
|
||||||
data: {
|
data: {
|
||||||
@@ -299,6 +329,11 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
alertDaysBefore: dto.alertDaysBefore,
|
alertDaysBefore: dto.alertDaysBefore,
|
||||||
}),
|
}),
|
||||||
...(dto.isActive !== undefined && { isActive: dto.isActive }),
|
...(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,
|
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);
|
return this.getPlugin(plugin.id);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -413,7 +453,7 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
tenant: { isActive: true },
|
tenant: { isActive: true },
|
||||||
},
|
},
|
||||||
include: {
|
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;
|
let sent = 0;
|
||||||
|
|
||||||
for (const plugin of plugins) {
|
for (const plugin of plugins) {
|
||||||
|
if (plugin.tenant.product === 'khanegi' && plugin.settingKey) continue;
|
||||||
if (!plugin.expiresAt) continue;
|
if (!plugin.expiresAt) continue;
|
||||||
const daysLeft = iranDaysUntil(plugin.expiresAt);
|
const daysLeft = iranDaysUntil(plugin.expiresAt);
|
||||||
const threshold = plugin.alertDaysBefore ?? 3;
|
const threshold = plugin.alertDaysBefore ?? 3;
|
||||||
@@ -522,6 +563,8 @@ export class PluginsService implements OnModuleInit, OnModuleDestroy {
|
|||||||
description: plugin.description,
|
description: plugin.description,
|
||||||
alertDaysBefore: plugin.alertDaysBefore,
|
alertDaysBefore: plugin.alertDaysBefore,
|
||||||
lastAlertDate: plugin.lastAlertDate?.toISOString() ?? null,
|
lastAlertDate: plugin.lastAlertDate?.toISOString() ?? null,
|
||||||
|
settingKey: plugin.settingKey ?? null,
|
||||||
|
autoCutOnExpiry: plugin.autoCutOnExpiry ?? false,
|
||||||
isActive: plugin.isActive,
|
isActive: plugin.isActive,
|
||||||
updatedAt: plugin.updatedAt.toISOString(),
|
updatedAt: plugin.updatedAt.toISOString(),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -57,6 +57,7 @@ type TemplateSeedRow = {
|
|||||||
@Injectable()
|
@Injectable()
|
||||||
export class ProgramSettingsService implements OnModuleInit {
|
export class ProgramSettingsService implements OnModuleInit {
|
||||||
private readonly logger = new Logger(ProgramSettingsService.name);
|
private readonly logger = new Logger(ProgramSettingsService.name);
|
||||||
|
private extraOwnedKeys = new Set<string>();
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly prisma: PrismaService,
|
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})`);
|
this.logger.log(`purged non-owned keys from setting_templates (templates=${purged.templates})`);
|
||||||
}
|
}
|
||||||
await this.seedEmptyKhanegiTenants();
|
await this.seedEmptyKhanegiTenants();
|
||||||
|
await this.refreshExtraOwnedKeys();
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
this.logger.warn(`setting_templates seed skipped: ${error?.message || error}`);
|
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ها */
|
/** فقط can_edit=0 نهایی deploy_settings_const — نه کل INSERTها */
|
||||||
private deployCatalogKeySet() {
|
private deployCatalogKeySet() {
|
||||||
return new Set<string>(DEPLOY_SETTING_CATALOG_KEYS);
|
return new Set<string>([...DEPLOY_SETTING_CATALOG_KEYS, ...this.extraOwnedKeys]);
|
||||||
}
|
}
|
||||||
|
|
||||||
private isSystemProgramSettingKey(key: string) {
|
private isSystemProgramSettingKey(key: string) {
|
||||||
const k = String(key || '').trim();
|
const k = String(key || '').trim();
|
||||||
if (!k || isRuntimeProgramSettingKey(k)) return false;
|
if (!k || isRuntimeProgramSettingKey(k)) return false;
|
||||||
if (DEPLOY_SETTING_EDITABLE_KEYS.has(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);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { ProgramSettingsService } from '../program-settings/program-settings.ser
|
|||||||
import { ReplicaWatchService } from '../replica-watch/replica-watch.service';
|
import { ReplicaWatchService } from '../replica-watch/replica-watch.service';
|
||||||
import { CreateTenantDto } from './dto/create-tenant.dto';
|
import { CreateTenantDto } from './dto/create-tenant.dto';
|
||||||
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
import { UpdateTenantDto } from './dto/update-tenant.dto';
|
||||||
|
import { rewriteLegacyUploadUrl } from '../uploads/uploads.util';
|
||||||
|
|
||||||
@Injectable()
|
@Injectable()
|
||||||
export class TenantsService {
|
export class TenantsService {
|
||||||
@@ -55,6 +56,7 @@ export class TenantsService {
|
|||||||
|
|
||||||
return rows.map(({ appUiConfig, _count, ...rest }: any) => ({
|
return rows.map(({ appUiConfig, _count, ...rest }: any) => ({
|
||||||
...rest,
|
...rest,
|
||||||
|
avatarUrl: rewriteLegacyUploadUrl(rest.avatarUrl) || rest.avatarUrl,
|
||||||
hasAppUiConfig: Boolean(appUiConfig),
|
hasAppUiConfig: Boolean(appUiConfig),
|
||||||
hasProgramSettings: (_count?.settings ?? 0) > 0,
|
hasProgramSettings: (_count?.settings ?? 0) > 0,
|
||||||
}));
|
}));
|
||||||
@@ -87,7 +89,7 @@ export class TenantsService {
|
|||||||
nameEn: dto.nameEn,
|
nameEn: dto.nameEn,
|
||||||
domain,
|
domain,
|
||||||
domains: aliases as unknown as Prisma.InputJsonValue,
|
domains: aliases as unknown as Prisma.InputJsonValue,
|
||||||
avatarUrl: dto.avatarUrl,
|
avatarUrl: rewriteLegacyUploadUrl(dto.avatarUrl) || dto.avatarUrl,
|
||||||
primaryColor: dto.primaryColor,
|
primaryColor: dto.primaryColor,
|
||||||
secondaryColor: dto.secondaryColor,
|
secondaryColor: dto.secondaryColor,
|
||||||
backgroundColor: dto.backgroundColor,
|
backgroundColor: dto.backgroundColor,
|
||||||
@@ -211,7 +213,9 @@ export class TenantsService {
|
|||||||
...(nextAliases !== undefined && {
|
...(nextAliases !== undefined && {
|
||||||
domains: nextAliases as unknown as Prisma.InputJsonValue,
|
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 && {
|
...(dto.primaryColor !== undefined && {
|
||||||
primaryColor: dto.primaryColor,
|
primaryColor: dto.primaryColor,
|
||||||
}),
|
}),
|
||||||
@@ -307,6 +311,7 @@ export class TenantsService {
|
|||||||
const { remoteApiToken, ...rest } = tenant;
|
const { remoteApiToken, ...rest } = tenant;
|
||||||
return {
|
return {
|
||||||
...rest,
|
...rest,
|
||||||
|
avatarUrl: rewriteLegacyUploadUrl(rest.avatarUrl) || rest.avatarUrl,
|
||||||
domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)),
|
domains: this.uniqueNormalizedHosts(this.readDomains(tenant.domains)),
|
||||||
hasRemoteApiToken: Boolean(remoteApiToken),
|
hasRemoteApiToken: Boolean(remoteApiToken),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -18,3 +18,45 @@ export function publicApiBase(config: {
|
|||||||
const port = config.get('PORT', '3001') ?? '3001';
|
const port = config.get('PORT', '3001') ?? '3001';
|
||||||
return `http://localhost:${port}`;
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user