Store enablement is now a tenant settings row instead of a global runtime switch, and expiring/requests endpoints degrade to empty lists when store tables are missing. Co-authored-by: Cursor <cursoragent@cursor.com>
824 lines
27 KiB
TypeScript
824 lines
27 KiB
TypeScript
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 { ProgramSettingsService } from '../program-settings/program-settings.service';
|
|
import { PLUGIN_STORE_SETTING_KEY } from '../program-settings/runtime-setting-keys';
|
|
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 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 isStoreEnabled(tenantId: string): Promise<boolean> {
|
|
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<Map<string, boolean>> {
|
|
const map = new Map<string, boolean>();
|
|
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;
|
|
try {
|
|
await this.programSettings.setOwnedSwitch(tenantId, settingKey, on);
|
|
} catch (error: any) {
|
|
this.logger.warn(
|
|
`setOwnedSwitch ${settingKey}=${on} tenant=${tenantId}: ${error?.message || error}`,
|
|
);
|
|
}
|
|
}
|
|
|
|
async listCatalogAdmin() {
|
|
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) {
|
|
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) {
|
|
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 } },
|
|
},
|
|
});
|
|
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) {
|
|
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 };
|
|
}
|
|
|
|
async remoteStore(tenantId: string, product: string) {
|
|
await 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) {
|
|
await 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,
|
|
) {
|
|
await 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) {
|
|
await 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 async assertRemoteKhanegi(tenantId: string, product: string) {
|
|
if (product !== 'khanegi') {
|
|
throw new ForbiddenException('فروشگاه افزونه فقط برای خانگی است');
|
|
}
|
|
if (!tenantId) throw new ForbiddenException('نسخه نامعتبر است');
|
|
if (!(await this.isStoreEnabled(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);
|
|
}
|
|
}
|