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:
@@ -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;
|
||||||
@@ -343,6 +343,7 @@ model PluginCatalogItem {
|
|||||||
settingKey String @unique @map("setting_key") @db.VarChar(50)
|
settingKey String @unique @map("setting_key") @db.VarChar(50)
|
||||||
titleFa String @map("title_fa")
|
titleFa String @map("title_fa")
|
||||||
description String? @db.Text
|
description String? @db.Text
|
||||||
|
images Json?
|
||||||
inStore Boolean @default(true) @map("in_store")
|
inStore Boolean @default(true) @map("in_store")
|
||||||
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")
|
||||||
@@ -377,6 +378,7 @@ model PluginPurchaseRequest {
|
|||||||
status PluginPurchaseStatus @default(pending)
|
status PluginPurchaseStatus @default(pending)
|
||||||
autoCutOnExpiry Boolean? @map("auto_cut_on_expiry")
|
autoCutOnExpiry Boolean? @map("auto_cut_on_expiry")
|
||||||
pluginSubscriptionId String? @map("plugin_subscription_id")
|
pluginSubscriptionId String? @map("plugin_subscription_id")
|
||||||
|
receiptUrl String? @map("receipt_url")
|
||||||
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")
|
||||||
decidedAt DateTime? @map("decided_at")
|
decidedAt DateTime? @map("decided_at")
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
|
ArrayMaxSize,
|
||||||
IsArray,
|
IsArray,
|
||||||
IsBoolean,
|
IsBoolean,
|
||||||
IsIn,
|
IsIn,
|
||||||
@@ -46,9 +47,15 @@ export class SaveCatalogItemDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(4000)
|
||||||
description?: string | null;
|
description?: string | null;
|
||||||
|
|
||||||
|
@IsOptional()
|
||||||
|
@IsArray()
|
||||||
|
@IsString({ each: true })
|
||||||
|
@ArrayMaxSize(3)
|
||||||
|
images?: string[];
|
||||||
|
|
||||||
@IsArray()
|
@IsArray()
|
||||||
@ValidateNested({ each: true })
|
@ValidateNested({ each: true })
|
||||||
@Type(() => CatalogPriceInputDto)
|
@Type(() => CatalogPriceInputDto)
|
||||||
@@ -69,7 +76,7 @@ export class CreateCatalogSettingDto {
|
|||||||
|
|
||||||
@IsOptional()
|
@IsOptional()
|
||||||
@IsString()
|
@IsString()
|
||||||
@MaxLength(2000)
|
@MaxLength(4000)
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@IsOptional()
|
@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 { JwtAuthGuard } from '../auth/jwt-auth.guard';
|
||||||
import { PermissionsGuard } from '../auth/permissions.guard';
|
import { PermissionsGuard } from '../auth/permissions.guard';
|
||||||
import { RequirePermissions } from '../auth/permissions.decorator';
|
import { RequirePermissions } from '../auth/permissions.decorator';
|
||||||
import { CurrentUser } from '../auth/current-user.decorator';
|
import { CurrentUser } from '../auth/current-user.decorator';
|
||||||
|
import {
|
||||||
|
IMAGE_MIME,
|
||||||
|
PLUGIN_IMAGES_DIR,
|
||||||
|
ensureUploadsDirs,
|
||||||
|
publicApiBase,
|
||||||
|
} from '../uploads/uploads.util';
|
||||||
import {
|
import {
|
||||||
ApprovePurchaseDto,
|
ApprovePurchaseDto,
|
||||||
CreateCatalogSettingDto,
|
CreateCatalogSettingDto,
|
||||||
@@ -13,7 +35,10 @@ import { PluginStoreService } from './plugin-store.service';
|
|||||||
@Controller('plugins/store')
|
@Controller('plugins/store')
|
||||||
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
@UseGuards(JwtAuthGuard, PermissionsGuard)
|
||||||
export class PluginStoreController {
|
export class PluginStoreController {
|
||||||
constructor(private readonly store: PluginStoreService) {}
|
constructor(
|
||||||
|
private readonly store: PluginStoreService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('catalog')
|
@Get('catalog')
|
||||||
@RequirePermissions('plugins:read')
|
@RequirePermissions('plugins:read')
|
||||||
@@ -21,6 +46,37 @@ export class PluginStoreController {
|
|||||||
return this.store.listCatalogAdmin();
|
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')
|
@Patch('catalog/:id')
|
||||||
@RequirePermissions('plugins:write')
|
@RequirePermissions('plugins:write')
|
||||||
saveItem(
|
saveItem(
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
formatIranDate,
|
formatIranDate,
|
||||||
iranDaysUntil,
|
iranDaysUntil,
|
||||||
} from '../common/iran-time.util';
|
} from '../common/iran-time.util';
|
||||||
|
import { rewriteLegacyUploadUrl } from '../uploads/uploads.util';
|
||||||
import {
|
import {
|
||||||
daysToPlanMonths,
|
daysToPlanMonths,
|
||||||
inferSettingKeyFromName,
|
inferSettingKeyFromName,
|
||||||
@@ -252,6 +253,9 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
|
|||||||
...(dto.description !== undefined && {
|
...(dto.description !== undefined && {
|
||||||
description: dto.description?.trim() || null,
|
description: dto.description?.trim() || null,
|
||||||
}),
|
}),
|
||||||
|
...(dto.images !== undefined && {
|
||||||
|
images: this.normalizeImageList(dto.images) as Prisma.InputJsonValue,
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
for (const price of dto.prices) {
|
for (const price of dto.prices) {
|
||||||
@@ -528,6 +532,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
|
|||||||
settingKey: item.settingKey,
|
settingKey: item.settingKey,
|
||||||
titleFa: item.titleFa,
|
titleFa: item.titleFa,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
|
images: this.normalizeImageList(item.images),
|
||||||
prices: enabledPrices.map((p) => ({
|
prices: enabledPrices.map((p) => ({
|
||||||
durationDays: p.durationDays,
|
durationDays: p.durationDays,
|
||||||
originalPriceRials: p.originalPriceRials,
|
originalPriceRials: p.originalPriceRials,
|
||||||
@@ -655,6 +660,38 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
|
|||||||
return { ok: true };
|
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() {
|
async runKhanegiExpiry() {
|
||||||
try {
|
try {
|
||||||
const plugins = await this.prisma.pluginSubscription.findMany({
|
const plugins = await this.prisma.pluginSubscription.findMany({
|
||||||
@@ -759,6 +796,7 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
|
|||||||
settingKey: item.settingKey,
|
settingKey: item.settingKey,
|
||||||
titleFa: item.titleFa,
|
titleFa: item.titleFa,
|
||||||
description: item.description,
|
description: item.description,
|
||||||
|
images: this.normalizeImageList(item.images),
|
||||||
inStore: item.inStore,
|
inStore: item.inStore,
|
||||||
prices: PLUGIN_STORE_DURATIONS.map((days) => {
|
prices: PLUGIN_STORE_DURATIONS.map((days) => {
|
||||||
const row = priceMap.get(days);
|
const row = priceMap.get(days);
|
||||||
@@ -795,11 +833,22 @@ export class PluginStoreService implements OnModuleInit, OnModuleDestroy {
|
|||||||
finalPriceRials: row.finalPriceRials,
|
finalPriceRials: row.finalPriceRials,
|
||||||
status: row.status,
|
status: row.status,
|
||||||
autoCutOnExpiry: row.autoCutOnExpiry,
|
autoCutOnExpiry: row.autoCutOnExpiry,
|
||||||
|
receiptUrl: rewriteLegacyUploadUrl(row.receiptUrl) || null,
|
||||||
createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
|
createdAt: row.createdAt?.toISOString?.() ?? row.createdAt,
|
||||||
decidedAt: row.decidedAt?.toISOString?.() ?? row.decidedAt ?? null,
|
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> {
|
private readMarks(raw: unknown, expiresAt: Date): Record<string, any> {
|
||||||
const iso = expiresAt.toISOString();
|
const iso = expiresAt.toISOString();
|
||||||
if (!raw || typeof raw !== 'object') return { _exp: iso };
|
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 { RemoteTenant } from '../auth/remote-tenant.decorator';
|
||||||
import { RemoteSmsAuthGuard } from '../auth/remote-sms-auth.guard';
|
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 { CreatePurchaseRequestDto } from './dto/plugin-store.dto';
|
||||||
import { PluginStoreService } from './plugin-store.service';
|
import { PluginStoreService } from './plugin-store.service';
|
||||||
|
|
||||||
@@ -15,7 +35,10 @@ type RemoteTenantInfo = {
|
|||||||
@Controller('remote/plugins')
|
@Controller('remote/plugins')
|
||||||
@UseGuards(RemoteSmsAuthGuard)
|
@UseGuards(RemoteSmsAuthGuard)
|
||||||
export class RemotePluginsController {
|
export class RemotePluginsController {
|
||||||
constructor(private readonly store: PluginStoreService) {}
|
constructor(
|
||||||
|
private readonly store: PluginStoreService,
|
||||||
|
private readonly config: ConfigService,
|
||||||
|
) {}
|
||||||
|
|
||||||
@Get('store')
|
@Get('store')
|
||||||
storeFront(@RemoteTenant() tenant: RemoteTenantInfo) {
|
storeFront(@RemoteTenant() tenant: RemoteTenantInfo) {
|
||||||
@@ -42,4 +65,43 @@ export class RemotePluginsController {
|
|||||||
) {
|
) {
|
||||||
return this.store.remoteCancelRequest(tenant.id, tenant.product, id);
|
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 UPLOADS_ROOT = join(process.cwd(), 'uploads');
|
||||||
export const AVATARS_DIR = join(UPLOADS_ROOT, 'avatars');
|
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() {
|
export function ensureUploadsDirs() {
|
||||||
if (!existsSync(AVATARS_DIR)) {
|
for (const dir of [AVATARS_DIR, PLUGIN_IMAGES_DIR, PLUGIN_RECEIPTS_DIR]) {
|
||||||
mkdirSync(AVATARS_DIR, { recursive: true });
|
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user