feat: complete admin panel and backend admin api migration
This commit is contained in:
@@ -0,0 +1,93 @@
|
||||
import { Body, Controller, Delete, Get, HttpCode, Param, Post, Put } from '@nestjs/common';
|
||||
import { AdminPromotionsService } from './admin-promotions.service';
|
||||
import { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
import { RequirePermission } from '../auth/guards';
|
||||
|
||||
/**
|
||||
* Legacy endpoints:
|
||||
* GET/POST/PUT/DELETE product/variant/promotion -> PromotionController
|
||||
* GET /autopromotion -> the auto-discount screen
|
||||
* GET/POST/DELETE /ignore -> views/offersIgnore
|
||||
*/
|
||||
@Controller('admin/promotions')
|
||||
export class AdminPromotionsController {
|
||||
constructor(private readonly promotions: AdminPromotionsService) {}
|
||||
|
||||
@Post('list')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.list')
|
||||
list(@Body() query: TableQueryDto) {
|
||||
return this.promotions.list(query);
|
||||
}
|
||||
|
||||
@Get('scope-options')
|
||||
@RequirePermission('offers.list')
|
||||
scopeOptions() {
|
||||
return this.promotions.scopeOptions();
|
||||
}
|
||||
|
||||
@Get('usage-options')
|
||||
@RequirePermission('offers.list')
|
||||
usageOptions() {
|
||||
return this.promotions.usageOptions();
|
||||
}
|
||||
|
||||
@Get('auto')
|
||||
@RequirePermission('offers.autopromotion')
|
||||
getAutoPromotion() {
|
||||
return this.promotions.getAutoPromotion();
|
||||
}
|
||||
|
||||
@Post('auto')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.autopromotion')
|
||||
setAutoPromotion(@Body() body: { state?: boolean; discount_percent?: number | string }) {
|
||||
return this.promotions.setAutoPromotion(body);
|
||||
}
|
||||
|
||||
// ── exclusions ────────────────────────────────────────────────────
|
||||
@Get('exclusions')
|
||||
@RequirePermission('offersIgnore')
|
||||
listExclusions() {
|
||||
return this.promotions.listExclusions();
|
||||
}
|
||||
|
||||
@Post('exclusions')
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offersIgnore')
|
||||
createExclusion(@Body() body: { type: number; item_id?: string[] }) {
|
||||
return this.promotions.createExclusion(body);
|
||||
}
|
||||
|
||||
@Delete('exclusions/:id')
|
||||
@RequirePermission('offersIgnore')
|
||||
removeExclusion(@Param('id') id: string) {
|
||||
return this.promotions.removeExclusion(id);
|
||||
}
|
||||
|
||||
// ── promotions ────────────────────────────────────────────────────
|
||||
@Post()
|
||||
@HttpCode(200)
|
||||
@RequirePermission('offers.create')
|
||||
create(@Body() data: Record<string, unknown>) {
|
||||
return this.promotions.create(data);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@RequirePermission('offers.list')
|
||||
findOne(@Param('id') id: string) {
|
||||
return this.promotions.findOne(id);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@RequirePermission('offers.list.edit')
|
||||
update(@Param('id') id: string, @Body() data: Record<string, unknown>) {
|
||||
return this.promotions.update(id, data);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
@RequirePermission('offers.list.edit')
|
||||
remove(@Param('id') id: string) {
|
||||
return this.promotions.remove(id);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { PrismaModule } from '../../prisma/prisma.module';
|
||||
import { AdminPromotionsController } from './admin-promotions.controller';
|
||||
import { AdminPromotionsService } from './admin-promotions.service';
|
||||
|
||||
@Module({
|
||||
imports: [PrismaModule],
|
||||
controllers: [AdminPromotionsController],
|
||||
providers: [AdminPromotionsService],
|
||||
})
|
||||
export class AdminPromotionsModule {}
|
||||
@@ -0,0 +1,334 @@
|
||||
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
|
||||
import { Prisma, PromotionScope, PromotionUsage } from '@prisma/client';
|
||||
import { PrismaService } from '../../prisma/prisma.service';
|
||||
import { TableService } from '../admin-table/table.service';
|
||||
import type { TableQueryDto } from '../admin-table/table-query.dto';
|
||||
|
||||
/**
|
||||
* Promotions — legacy views/promotion/*, served by PromotionController
|
||||
* (`GET/POST/PUT/DELETE product/variant/promotion`) plus the exclusion list
|
||||
* (`/ignore`) behind views/offersIgnore.
|
||||
*
|
||||
* Legacy `type` (scope): 1 محصولات | 2 دسته بندی | 3 مشتری | 4 برند
|
||||
* Legacy `use_only` (usage): 1 فقط یکبار | 2 تا تاریخ پایان
|
||||
*/
|
||||
const SCOPE_BY_CODE: Record<number, PromotionScope> = {
|
||||
1: PromotionScope.PRODUCT,
|
||||
2: PromotionScope.CATEGORY,
|
||||
3: PromotionScope.CUSTOMER,
|
||||
4: PromotionScope.BRAND,
|
||||
};
|
||||
|
||||
const CODE_BY_SCOPE: Record<PromotionScope, number> = {
|
||||
PRODUCT: 1,
|
||||
CATEGORY: 2,
|
||||
CUSTOMER: 3,
|
||||
BRAND: 4,
|
||||
};
|
||||
|
||||
const SCOPE_LABELS: Record<PromotionScope, string> = {
|
||||
PRODUCT: 'محصولات',
|
||||
CATEGORY: 'دسته بندی',
|
||||
CUSTOMER: 'مشتری',
|
||||
BRAND: 'برند',
|
||||
};
|
||||
|
||||
const USAGE_BY_CODE: Record<number, PromotionUsage> = {
|
||||
1: PromotionUsage.ONCE,
|
||||
2: PromotionUsage.UNTIL_END_DATE,
|
||||
};
|
||||
|
||||
const CODE_BY_USAGE: Record<PromotionUsage, number> = {
|
||||
ONCE: 1,
|
||||
UNTIL_END_DATE: 2,
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class AdminPromotionsService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly table: TableService,
|
||||
) {}
|
||||
|
||||
private static readonly ALLOWED = [
|
||||
'id',
|
||||
'title',
|
||||
'scope',
|
||||
'usage',
|
||||
'discountPercent',
|
||||
'isActive',
|
||||
'platform',
|
||||
'startsAt',
|
||||
'endsAt',
|
||||
'createdAt',
|
||||
// legacy filter keys
|
||||
'promotion_type_str',
|
||||
'status',
|
||||
'start',
|
||||
'end',
|
||||
'discount_percent',
|
||||
];
|
||||
|
||||
private static readonly FIELD_ALIASES: Record<string, string> = {
|
||||
promotion_type_str: 'scope',
|
||||
type: 'scope',
|
||||
use_only: 'usage',
|
||||
status: 'isActive',
|
||||
start: 'startsAt',
|
||||
end: 'endsAt',
|
||||
discount_percent: 'discountPercent',
|
||||
caj: 'createdAt',
|
||||
};
|
||||
|
||||
/** Legacy `GET product/variant/promotion` — the promotions grid. */
|
||||
async list(query: TableQueryDto) {
|
||||
this.table.assertNotExport(query);
|
||||
|
||||
const mapField = (f?: string) => (f ? (AdminPromotionsService.FIELD_ALIASES[f] ?? f) : f);
|
||||
const aliased: TableQueryDto = {
|
||||
...query,
|
||||
filters: (query.filters ?? []).map((f) => {
|
||||
const mapped = { ...f, field: mapField(f.field) };
|
||||
if (mapped.field === 'scope' && mapped.value !== undefined) {
|
||||
mapped.value = SCOPE_BY_CODE[Number(mapped.value)] ?? mapped.value;
|
||||
}
|
||||
if (mapped.field === 'isActive' && mapped.value !== undefined) {
|
||||
mapped.value = String(mapped.value) === '1';
|
||||
}
|
||||
return mapped;
|
||||
}),
|
||||
order: query.order
|
||||
? `${query.order.startsWith('-') ? '-' : '+'}${mapField(query.order.replace(/^[+-]/, ''))}`
|
||||
: undefined,
|
||||
};
|
||||
|
||||
const { where, orderBy, skip, take, page, limit } = this.table.build(aliased, {
|
||||
searchable: ['title', 'description'],
|
||||
allowed: AdminPromotionsService.ALLOWED,
|
||||
defaultOrderBy: { createdAt: 'desc' },
|
||||
});
|
||||
|
||||
const [rows, total] = await Promise.all([
|
||||
this.prisma.promotion.findMany({
|
||||
where: where as Prisma.PromotionWhereInput,
|
||||
orderBy: orderBy as Prisma.PromotionOrderByWithRelationInput,
|
||||
skip,
|
||||
take,
|
||||
include: { product: true, category: true, customer: true, brand: true },
|
||||
}),
|
||||
this.prisma.promotion.count({ where: where as Prisma.PromotionWhereInput }),
|
||||
]);
|
||||
|
||||
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
|
||||
}
|
||||
|
||||
private toRow(
|
||||
p: Prisma.PromotionGetPayload<{
|
||||
include: { product: true; category: true; customer: true; brand: true };
|
||||
}>,
|
||||
) {
|
||||
return {
|
||||
id: p.id,
|
||||
title: p.title,
|
||||
description: p.description,
|
||||
type: CODE_BY_SCOPE[p.scope],
|
||||
promotion_type_str: SCOPE_LABELS[p.scope],
|
||||
use_only: CODE_BY_USAGE[p.usage],
|
||||
discount_percent: p.discountPercent,
|
||||
// legacy grid renders `status` through a slot
|
||||
status: p.isActive ? 1 : 0,
|
||||
is_active: p.isActive,
|
||||
/**
|
||||
* Legacy `type_customer`: 1 = تک (retail), 2 = عمده (wholesale). The grid
|
||||
* uses it BOTH to pick which permission gates the row's edit/delete icons
|
||||
* AND to choose which edit route opens. A customer-scoped promotion is the
|
||||
* retail ("tak") kind; everything else is the wholesale kind.
|
||||
*/
|
||||
type_customer: p.scope === PromotionScope.CUSTOMER ? 1 : 2,
|
||||
platform: p.platform,
|
||||
start: p.startsAt,
|
||||
end: p.endsAt,
|
||||
product_id: p.productId,
|
||||
category_id: p.categoryId,
|
||||
customer_id: p.customerId,
|
||||
brand_id: p.brandId,
|
||||
product: p.product ? { id: p.product.id, title: p.product.title } : null,
|
||||
category: p.category ? { id: p.category.id, title: p.category.title } : null,
|
||||
customer: p.customer ? { id: p.customer.id, name: p.customer.fullName } : null,
|
||||
brand: p.brand ? { id: p.brand.id, title: p.brand.title } : null,
|
||||
caj: p.createdAt,
|
||||
};
|
||||
}
|
||||
|
||||
async findOne(id: string) {
|
||||
const p = await this.prisma.promotion.findUnique({
|
||||
where: { id },
|
||||
include: { product: true, category: true, customer: true, brand: true },
|
||||
});
|
||||
if (!p) throw new NotFoundException('تخفیف یافت نشد');
|
||||
return this.toRow(p);
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps the legacy form payload onto the model, attaching the ONE target that
|
||||
* matches the chosen scope and clearing the others — so a promotion can never
|
||||
* carry, say, both a product and a customer.
|
||||
*/
|
||||
private buildData(data: Record<string, unknown>) {
|
||||
const scope = SCOPE_BY_CODE[Number(data.type ?? 1)];
|
||||
if (!scope) throw new BadRequestException('نوع تخفیف نامعتبر است');
|
||||
|
||||
const usage = USAGE_BY_CODE[Number(data.use_only ?? 1)] ?? PromotionUsage.ONCE;
|
||||
const percent = new Prisma.Decimal(String(data.discount_percent ?? 0));
|
||||
if (percent.lt(0) || percent.gt(100)) {
|
||||
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
|
||||
const startsAt = data.start ? new Date(String(data.start)) : null;
|
||||
const endsAt = data.end ? new Date(String(data.end)) : null;
|
||||
if (startsAt && endsAt && endsAt < startsAt) {
|
||||
throw new BadRequestException('تاریخ پایان نمیتواند قبل از تاریخ شروع باشد');
|
||||
}
|
||||
|
||||
return {
|
||||
title: String(data.title ?? '').trim(),
|
||||
description: (data.description as string) || null,
|
||||
scope,
|
||||
usage,
|
||||
discountPercent: percent,
|
||||
isActive: data.is_active !== undefined ? Boolean(data.is_active) : true,
|
||||
platform: data.platform !== undefined ? Number(data.platform) : 3,
|
||||
startsAt,
|
||||
endsAt,
|
||||
productId: scope === PromotionScope.PRODUCT ? ((data.product_id as string) ?? null) : null,
|
||||
categoryId: scope === PromotionScope.CATEGORY ? ((data.category_id as string) ?? null) : null,
|
||||
customerId: scope === PromotionScope.CUSTOMER ? ((data.customer_id as string) ?? null) : null,
|
||||
brandId: scope === PromotionScope.BRAND ? ((data.brand_id as string) ?? null) : null,
|
||||
};
|
||||
}
|
||||
|
||||
/** Legacy `POST product/variant/promotion`. */
|
||||
async create(data: Record<string, unknown>) {
|
||||
const payload = this.buildData(data);
|
||||
if (!payload.title) throw new BadRequestException('عنوان تخفیف الزامی است');
|
||||
return this.prisma.promotion.create({ data: payload });
|
||||
}
|
||||
|
||||
/** Legacy `PUT product/variant/promotion/{id}`. */
|
||||
async update(id: string, data: Record<string, unknown>) {
|
||||
const existing = await this.prisma.promotion.findUnique({ where: { id } });
|
||||
if (!existing) throw new NotFoundException('تخفیف یافت نشد');
|
||||
// legacy disabled the type selector on edit (`:disabled="this.id"`), so the
|
||||
// scope is not re-read here either.
|
||||
const payload = this.buildData({ ...data, type: CODE_BY_SCOPE[existing.scope] });
|
||||
return this.prisma.promotion.update({ where: { id }, data: payload });
|
||||
}
|
||||
|
||||
/** Legacy `DELETE product/variant/promotion/{id}`. */
|
||||
async remove(id: string) {
|
||||
const p = await this.prisma.promotion.findUnique({ where: { id } });
|
||||
if (!p) throw new NotFoundException('تخفیف یافت نشد');
|
||||
await this.prisma.promotion.delete({ where: { id } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
scopeOptions() {
|
||||
return Object.entries(SCOPE_BY_CODE).map(([code, scope]) => ({
|
||||
value: Number(code),
|
||||
title: SCOPE_LABELS[scope],
|
||||
}));
|
||||
}
|
||||
|
||||
usageOptions() {
|
||||
return [
|
||||
{ value: 1, title: ' فقط یکبار' },
|
||||
{ value: 2, title: 'تا تاریخ پایان' },
|
||||
];
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `GET /autopromotion` and its POST counterpart — the settings screen
|
||||
* for "تخفیف اتوماتیک یکبار مصرف ویژه مشتریان جدید": a one-shot welcome
|
||||
* discount for new customers, configured as an on/off state plus a percentage.
|
||||
*
|
||||
* Stored in the Setting key/value table, like the other config screens.
|
||||
*/
|
||||
async getAutoPromotion() {
|
||||
const row = await this.prisma.setting.findUnique({ where: { key: 'promotion.auto' } });
|
||||
return row?.value ?? { state: false, discount_percent: null };
|
||||
}
|
||||
|
||||
async setAutoPromotion(data: { state?: boolean; discount_percent?: number | string }) {
|
||||
const percent = Number(data.discount_percent ?? 0);
|
||||
if (Number.isNaN(percent) || percent < 0 || percent > 100) {
|
||||
throw new BadRequestException('درصد تخفیف باید بین ۰ تا ۱۰۰ باشد');
|
||||
}
|
||||
const value = { state: Boolean(data.state), discount_percent: percent };
|
||||
const row = await this.prisma.setting.upsert({
|
||||
where: { key: 'promotion.auto' },
|
||||
update: { value },
|
||||
create: { key: 'promotion.auto', value },
|
||||
});
|
||||
return row.value;
|
||||
}
|
||||
|
||||
// ── Exclusions (views/offersIgnore) ────────────────────────────────
|
||||
|
||||
/**
|
||||
* Legacy `GET /ignore` — the full exclusion list.
|
||||
*
|
||||
* The view reads it as a flat array and filters client-side:
|
||||
* items_id(type) { return this.ignoreList.filter(i => i.type == type).map(x => x.item_id) }
|
||||
* so the flat shape with `item_id` is preserved.
|
||||
*/
|
||||
async listExclusions() {
|
||||
const rows = await this.prisma.promotionExclusion.findMany({
|
||||
orderBy: [{ type: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
return rows.map((e) => ({ id: e.id, type: e.type, item_id: e.itemId }));
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `POST /ignore` with `{ type, item_id: [...] }`.
|
||||
*
|
||||
* The posted array is the COMPLETE selection for that type — the view binds it
|
||||
* to a checkbox table — so this replaces that type's rows rather than adding
|
||||
* to them. Anything unchecked is therefore removed.
|
||||
*/
|
||||
async createExclusion(data: { type: number; item_id?: string[] }) {
|
||||
const type = Number(data.type);
|
||||
if (!type) throw new BadRequestException('نوع استثناء الزامی است');
|
||||
|
||||
const itemIds = Array.isArray(data.item_id)
|
||||
? data.item_id.map((x) => String(x)).filter(Boolean)
|
||||
: [];
|
||||
|
||||
await this.prisma.$transaction([
|
||||
this.prisma.promotionExclusion.deleteMany({ where: { type } }),
|
||||
this.prisma.promotionExclusion.createMany({
|
||||
data: itemIds.map((itemId) => ({ type, itemId })),
|
||||
skipDuplicates: true,
|
||||
}),
|
||||
]);
|
||||
|
||||
return this.listExclusions();
|
||||
}
|
||||
|
||||
/**
|
||||
* Legacy `DELETE /ignore/{id}` — the view's "حذف نوع استثناء" card removes a
|
||||
* whole TYPE, so a numeric id is treated as a type and a cuid as one row.
|
||||
*/
|
||||
async removeExclusion(id: string) {
|
||||
if (/^\d+$/.test(id)) {
|
||||
const type = Number(id);
|
||||
const { count } = await this.prisma.promotionExclusion.deleteMany({ where: { type } });
|
||||
if (count === 0) throw new NotFoundException('استثنایی برای این نوع یافت نشد');
|
||||
return { success: true, removed: count };
|
||||
}
|
||||
|
||||
const e = await this.prisma.promotionExclusion.findUnique({ where: { id } });
|
||||
if (!e) throw new NotFoundException('استثناء یافت نشد');
|
||||
await this.prisma.promotionExclusion.delete({ where: { id } });
|
||||
return { success: true, removed: 1 };
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user