feat: complete admin panel and backend admin api migration

This commit is contained in:
s.esmaeili
2026-09-13 21:02:25 +03:30
parent 03119cd655
commit 267fc7b7d1
239 changed files with 25431 additions and 935 deletions
@@ -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 };
}
}