feat(plugins): catalog images and purchase receipt uploads

Catalog items can store up to three photos; remote tenants attach a receipt on pending requests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-09-08 14:56:09 +03:30
co-authored by Cursor
parent 18ac137e25
commit 67a03d9593
7 changed files with 189 additions and 8 deletions
@@ -0,0 +1,2 @@
ALTER TABLE "plugin_catalog_items" ADD COLUMN IF NOT EXISTS "images" JSONB;
ALTER TABLE "plugin_purchase_requests" ADD COLUMN IF NOT EXISTS "receipt_url" TEXT;
+2
View File
@@ -343,6 +343,7 @@ model PluginCatalogItem {
settingKey String @unique @map("setting_key") @db.VarChar(50)
titleFa String @map("title_fa")
description String? @db.Text
images Json?
inStore Boolean @default(true) @map("in_store")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -377,6 +378,7 @@ model PluginPurchaseRequest {
status PluginPurchaseStatus @default(pending)
autoCutOnExpiry Boolean? @map("auto_cut_on_expiry")
pluginSubscriptionId String? @map("plugin_subscription_id")
receiptUrl String? @map("receipt_url")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
decidedAt DateTime? @map("decided_at")
+9 -2
View File
@@ -1,4 +1,5 @@
import {
ArrayMaxSize,
IsArray,
IsBoolean,
IsIn,
@@ -46,9 +47,15 @@ export class SaveCatalogItemDto {
@IsOptional()
@IsString()
@MaxLength(2000)
@MaxLength(4000)
description?: string | null;
@IsOptional()
@IsArray()
@IsString({ each: true })
@ArrayMaxSize(3)
images?: string[];
@IsArray()
@ValidateNested({ each: true })
@Type(() => CatalogPriceInputDto)
@@ -69,7 +76,7 @@ export class CreateCatalogSettingDto {
@IsOptional()
@IsString()
@MaxLength(2000)
@MaxLength(4000)
description?: string;
@IsOptional()
+58 -2
View File
@@ -1,8 +1,30 @@
import { Body, Controller, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
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 {
IMAGE_MIME,
PLUGIN_IMAGES_DIR,
ensureUploadsDirs,
publicApiBase,
} from '../uploads/uploads.util';
import {
ApprovePurchaseDto,
CreateCatalogSettingDto,
@@ -13,7 +35,10 @@ import { PluginStoreService } from './plugin-store.service';
@Controller('plugins/store')
@UseGuards(JwtAuthGuard, PermissionsGuard)
export class PluginStoreController {
constructor(private readonly store: PluginStoreService) {}
constructor(
private readonly store: PluginStoreService,
private readonly config: ConfigService,
) {}
@Get('catalog')
@RequirePermissions('plugins:read')
@@ -21,6 +46,37 @@ export class PluginStoreController {
return this.store.listCatalogAdmin();
}
@Post('upload-image')
@RequirePermissions('plugins:write')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => {
ensureUploadsDirs();
cb(null, PLUGIN_IMAGES_DIR);
},
filename: (_req, file, cb) => {
const safeExt = extname(file.originalname).toLowerCase() || '.png';
const name = `plugin_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${safeExt}`;
cb(null, name);
},
}),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!IMAGE_MIME.test(file.mimetype)) {
cb(new BadRequestException('فقط تصویر (jpg/png/gif/webp/svg) مجاز است') as any, false);
return;
}
cb(null, true);
},
}),
)
uploadImage(@UploadedFile() file: Express.Multer.File) {
if (!file) throw new BadRequestException('فایل ارسال نشده است');
const base = publicApiBase(this.config);
return { url: `${base}/uploads/plugins/${file.filename}` };
}
@Patch('catalog/:id')
@RequirePermissions('plugins:write')
saveItem(
+49
View File
@@ -30,6 +30,7 @@ import {
formatIranDate,
iranDaysUntil,
} from '../common/iran-time.util';
import { rewriteLegacyUploadUrl } from '../uploads/uploads.util';
import {
daysToPlanMonths,
inferSettingKeyFromName,
@@ -252,6 +253,9 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
...(dto.description !== undefined && {
description: dto.description?.trim() || null,
}),
...(dto.images !== undefined && {
images: this.normalizeImageList(dto.images) as Prisma.InputJsonValue,
}),
},
});
for (const price of dto.prices) {
@@ -528,6 +532,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
settingKey: item.settingKey,
titleFa: item.titleFa,
description: item.description,
images: this.normalizeImageList(item.images),
prices: enabledPrices.map((p) => ({
durationDays: p.durationDays,
originalPriceRials: p.originalPriceRials,
@@ -655,6 +660,38 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
return { ok: true };
}
async remoteAttachReceipt(
tenantId: string,
product: string,
requestId: string,
receiptUrl: string,
) {
await this.assertRemoteKhanegi(tenantId, product);
const url = rewriteLegacyUploadUrl(receiptUrl);
if (!url) throw new BadRequestException('عکس فیش لازم است');
const req = await this.prisma.pluginPurchaseRequest.findFirst({
where: { id: requestId, tenantId },
include: {
catalogItem: { select: { titleFa: true, settingKey: true } },
tenant: {
select: { id: true, nameFa: true, domain: true, slug: true, product: true },
},
},
});
if (!req) throw new NotFoundException('درخواست یافت نشد');
if (req.status !== 'pending') {
throw new BadRequestException('فقط برای درخواست در انتظار می‌توان فیش فرستاد');
}
const updated = await this.prisma.pluginPurchaseRequest.update({
where: { id: requestId },
data: { receiptUrl: url },
});
this.notify.notifyAdmins(
`فیش واریز افزونه: «${req.catalogItem.titleFa}» برای «${req.tenant.nameFa}» (${req.tenant.domain})`,
);
return this.toRequestRow({ ...req, receiptUrl: updated.receiptUrl });
}
async runKhanegiExpiry() {
try {
const plugins = await this.prisma.pluginSubscription.findMany({
@@ -759,6 +796,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
settingKey: item.settingKey,
titleFa: item.titleFa,
description: item.description,
images: this.normalizeImageList(item.images),
inStore: item.inStore,
prices: PLUGIN_STORE_DURATIONS.map((days) => {
const row = priceMap.get(days);
@@ -795,11 +833,22 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
finalPriceRials: row.finalPriceRials,
status: row.status,
autoCutOnExpiry: row.autoCutOnExpiry,
receiptUrl: rewriteLegacyUploadUrl(row.receiptUrl) || null,
createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
decidedAt: row.decidedAt?.toISOString?.() ?? row.decidedAt ?? null,
};
}
private normalizeImageList(raw: unknown): string[] {
if (!Array.isArray(raw)) return [];
const out: string[] = [];
for (const item of raw) {
const url = rewriteLegacyUploadUrl(item);
if (url && !out.includes(url) && out.length < 3) out.push(url);
}
return out;
}
private readMarks(raw: unknown, expiresAt: Date): Record<string, any> {
const iso = expiresAt.toISOString();
if (!raw || typeof raw !== 'object') return { _exp: iso };
+64 -2
View File
@@ -1,6 +1,26 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import {
BadRequestException,
Body,
Controller,
Get,
Param,
Post,
UploadedFile,
UseGuards,
UseInterceptors,
} from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { FileInterceptor } from '@nestjs/platform-express';
import { diskStorage } from 'multer';
import { extname } from 'path';
import { RemoteTenant } from '../auth/remote-tenant.decorator';
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
import {
IMAGE_MIME,
PLUGIN_RECEIPTS_DIR,
ensureUploadsDirs,
publicApiBase,
} from '../uploads/uploads.util';
import { CreatePurchaseRequestDto } from './dto/plugin-store.dto';
import { PluginStoreService } from './plugin-store.service';
@@ -15,7 +35,10 @@ type RemoteTenantInfo = {
@Controller('remote/plugins')
@UseGuards(RemoteSmsAuthGuard)
export class RemotePluginsController {
constructor(private readonly store: PluginStoreService) {}
constructor(
private readonly store: PluginStoreService,
private readonly config: ConfigService,
) {}
@Get('store')
storeFront(@RemoteTenant() tenant: RemoteTenantInfo) {
@@ -42,4 +65,43 @@ export class RemotePluginsController {
) {
return this.store.remoteCancelRequest(tenant.id, tenant.product, id);
}
@Post('requests/:id/receipt')
@UseInterceptors(
FileInterceptor('file', {
storage: diskStorage({
destination: (_req, _file, cb) => {
ensureUploadsDirs();
cb(null, PLUGIN_RECEIPTS_DIR);
},
filename: (_req, file, cb) => {
const safeExt = extname(file.originalname).toLowerCase() || '.png';
const name = `receipt_${Date.now()}_${Math.random().toString(36).slice(2, 8)}${safeExt}`;
cb(null, name);
},
}),
limits: { fileSize: 2 * 1024 * 1024 },
fileFilter: (_req, file, cb) => {
if (!IMAGE_MIME.test(file.mimetype)) {
cb(new BadRequestException('فقط تصویر (jpg/png/gif/webp/svg) مجاز است') as any, false);
return;
}
cb(null, true);
},
}),
)
receipt(
@RemoteTenant() tenant: RemoteTenantInfo,
@Param('id') id: string,
@UploadedFile() file: Express.Multer.File,
) {
if (!file) throw new BadRequestException('فایل ارسال نشده است');
const base = publicApiBase(this.config);
return this.store.remoteAttachReceipt(
tenant.id,
tenant.product,
id,
`${base}/uploads/plugin-receipts/${file.filename}`,
);
}
}
+5 -2
View File
@@ -3,10 +3,13 @@ import { join } from 'path';
export const UPLOADS_ROOT = join(process.cwd(), 'uploads');
export const AVATARS_DIR = join(UPLOADS_ROOT, 'avatars');
export const PLUGIN_IMAGES_DIR = join(UPLOADS_ROOT, 'plugins');
export const PLUGIN_RECEIPTS_DIR = join(UPLOADS_ROOT, 'plugin-receipts');
export const IMAGE_MIME = /^image\/(jpeg|png|gif|webp|svg\+xml)$/i;
export function ensureUploadsDirs() {
if (!existsSync(AVATARS_DIR)) {
mkdirSync(AVATARS_DIR, { recursive: true });
for (const dir of [AVATARS_DIR, PLUGIN_IMAGES_DIR, PLUGIN_RECEIPTS_DIR]) {
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
}
}