From 67a03d95935efd09351b65805b21258bef734e29 Mon Sep 17 00:00:00 2001 From: adonis Date: Tue, 8 Sep 2026 14:56:09 +0330 Subject: [PATCH] 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 --- .../migration.sql | 2 + prisma/schema.prisma | 2 + src/plugins/dto/plugin-store.dto.ts | 11 +++- src/plugins/plugin-store.controller.ts | 60 ++++++++++++++++- src/plugins/plugin-store.service.ts | 49 ++++++++++++++ src/plugins/remote-plugins.controller.ts | 66 ++++++++++++++++++- src/uploads/uploads.util.ts | 7 +- 7 files changed, 189 insertions(+), 8 deletions(-) create mode 100644 prisma/migrations/20260908120000_plugin_store_images_receipt/migration.sql diff --git a/prisma/migrations/20260908120000_plugin_store_images_receipt/migration.sql b/prisma/migrations/20260908120000_plugin_store_images_receipt/migration.sql new file mode 100644 index 0000000..f0d13ae --- /dev/null +++ b/prisma/migrations/20260908120000_plugin_store_images_receipt/migration.sql @@ -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; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index f379c12..4f3817c 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -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") diff --git a/src/plugins/dto/plugin-store.dto.ts b/src/plugins/dto/plugin-store.dto.ts index 37ab702..26fd42c 100644 --- a/src/plugins/dto/plugin-store.dto.ts +++ b/src/plugins/dto/plugin-store.dto.ts @@ -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() diff --git a/src/plugins/plugin-store.controller.ts b/src/plugins/plugin-store.controller.ts index a9a8b22..d7a9c4e 100644 --- a/src/plugins/plugin-store.controller.ts +++ b/src/plugins/plugin-store.controller.ts @@ -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( diff --git a/src/plugins/plugin-store.service.ts b/src/plugins/plugin-store.service.ts index 1b48c8c..4b93385 100644 --- a/src/plugins/plugin-store.service.ts +++ b/src/plugins/plugin-store.service.ts @@ -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 { const iso = expiresAt.toISOString(); if (!raw || typeof raw !== 'object') return { _exp: iso }; diff --git a/src/plugins/remote-plugins.controller.ts b/src/plugins/remote-plugins.controller.ts index bcaaa22..5a35490 100644 --- a/src/plugins/remote-plugins.controller.ts +++ b/src/plugins/remote-plugins.controller.ts @@ -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}`, + ); + } } diff --git a/src/uploads/uploads.util.ts b/src/uploads/uploads.util.ts index ed7eea5..3fb2b02 100644 --- a/src/uploads/uploads.util.ts +++ b/src/uploads/uploads.util.ts @@ -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 }); } }