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:
@@ -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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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 };
|
||||
|
||||
@@ -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}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user