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,59 @@
import { Body, Controller, Delete, Get, HttpCode, Param, Post } from '@nestjs/common';
import { AdminCommentsService } from './admin-comments.service';
import { TableQueryDto } from '../admin-table/table-query.dto';
import { RequirePermission } from '../auth/guards';
/**
* Legacy endpoints (CommentController, routes/admin.php:149-152):
* POST comment/table -> the moderation grid
* POST comment/sts -> approve / reject
* POST comment -> staff reply
* DELETE comment/{id}
*
* All gated on the legacy rid `comment`.
*/
@Controller('admin/comments')
export class AdminCommentsController {
constructor(private readonly comments: AdminCommentsService) {}
@Post('list')
@HttpCode(200)
@RequirePermission('comment')
list(@Body() query: TableQueryDto) {
return this.comments.list(query);
}
@Get('status-options')
@RequirePermission('comment')
statusOptions() {
return this.comments.statusOptions();
}
@Post('sts')
@HttpCode(200)
@RequirePermission('comment')
changeStatus(@Body() body: { id: string; sts: number }) {
return this.comments.changeStatus(body.id, body.sts);
}
@Post()
@HttpCode(200)
@RequirePermission('comment')
create(
@Body() body: { product_id: string; customer_id: string; comment: string; reply_to?: string },
) {
return this.comments.create(body);
}
@Get(':id')
@RequirePermission('comment')
findOne(@Param('id') id: string) {
return this.comments.findOne(id);
}
@Delete(':id')
@RequirePermission('comment')
remove(@Param('id') id: string) {
return this.comments.remove(id);
}
}
@@ -0,0 +1,11 @@
import { Module } from '@nestjs/common';
import { PrismaModule } from '../../prisma/prisma.module';
import { AdminCommentsController } from './admin-comments.controller';
import { AdminCommentsService } from './admin-comments.service';
@Module({
imports: [PrismaModule],
controllers: [AdminCommentsController],
providers: [AdminCommentsService],
})
export class AdminCommentsModule {}
@@ -0,0 +1,203 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { CommentStatus, Prisma } 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';
/**
* Product comment moderation — legacy views/customer/Comments/Comments.vue,
* served by CommentController (`POST comment/table`, `POST comment/sts`,
* `POST comment`, `DELETE comment/{id}`).
*
* Legacy Comment::$STS = [1 => 'در انتظار تایید', 2 => 'تایید شده', 3 => 'رد شده'].
* The numeric codes are preserved on the wire (`sts`) so the ported view's
* status dropdown posts unchanged, while the DB stores an enum.
*/
const STATUS_BY_CODE: Record<number, CommentStatus> = {
1: CommentStatus.PENDING,
2: CommentStatus.APPROVED,
3: CommentStatus.REJECTED,
};
const CODE_BY_STATUS: Record<CommentStatus, number> = {
PENDING: 1,
APPROVED: 2,
REJECTED: 3,
};
const STATUS_LABELS: Record<CommentStatus, string> = {
PENDING: 'در انتظار تایید',
APPROVED: 'تایید شده',
REJECTED: 'رد شده',
};
@Injectable()
export class AdminCommentsService {
constructor(
private readonly prisma: PrismaService,
private readonly table: TableService,
) {}
private static readonly ALLOWED = [
'id',
'status',
'createdAt',
'updatedAt',
'body',
'customer.fullName',
'customer.mobile',
'product.title',
// legacy filter keys
'sts',
'comment',
'caj',
];
private static readonly FIELD_ALIASES: Record<string, string> = {
sts: 'status',
comment: 'body',
caj: 'createdAt',
uaj: 'updatedAt',
'customer.name': 'customer.fullName',
'product.name': 'product.title',
};
/** Legacy `POST comment/table`. */
async list(query: TableQueryDto) {
this.table.assertNotExport(query);
const mapField = (f?: string) =>
f ? (AdminCommentsService.FIELD_ALIASES[f] ?? f) : f;
const aliased: TableQueryDto = {
...query,
filters: (query.filters ?? []).map((f) => {
const mapped = { ...f, field: mapField(f.field) };
// legacy sent the numeric status code
if (mapped.field === 'status' && mapped.value !== undefined) {
mapped.value = STATUS_BY_CODE[Number(mapped.value)] ?? mapped.value;
}
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: ['body', 'customer.fullName', 'product.title'],
allowed: AdminCommentsService.ALLOWED,
defaultOrderBy: { createdAt: 'desc' },
});
const [rows, total] = await Promise.all([
this.prisma.comment.findMany({
where: where as Prisma.CommentWhereInput,
orderBy: orderBy as Prisma.CommentOrderByWithRelationInput,
skip,
take,
include: { customer: true, product: true, replyTo: true, replies: true },
}),
this.prisma.comment.count({ where: where as Prisma.CommentWhereInput }),
]);
return this.table.respond(rows.map((r) => this.toRow(r)), total, query, page, limit);
}
private toRow(
c: Prisma.CommentGetPayload<{
include: { customer: true; product: true; replyTo: true; replies?: true };
}> & { replies?: { id: string; body: string }[] },
) {
return {
id: c.id,
comment: c.body,
body: c.body,
sts: CODE_BY_STATUS[c.status],
sts_str: STATUS_LABELS[c.status],
status: c.status,
reply_to: c.replyToId,
// legacy view reads: text.length > 0 && text[0].comment !== null
reply: (c.replies ?? []).map((r) => ({ id: r.id, comment: r.body })),
product_id: c.productId,
customer_id: c.customerId,
product: { id: c.product.id, title: c.product.title, name: c.product.title },
customer: {
id: c.customer.id,
name: c.customer.fullName,
fname: c.customer.fullName,
lname: '',
mobile: c.customer.mobile,
},
caj: c.createdAt,
uaj: c.updatedAt,
};
}
async findOne(id: string) {
const c = await this.prisma.comment.findUnique({
where: { id },
include: { customer: true, product: true, replyTo: true, replies: true },
});
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
return this.toRow(c);
}
/**
* Legacy `POST comment/sts` — moderate a comment.
* Payload is `{ id, sts }` where sts is 1/2/3.
*/
async changeStatus(id: string, sts: number) {
const c = await this.prisma.comment.findUnique({ where: { id } });
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
const status = STATUS_BY_CODE[Number(sts)];
if (!status) {
throw new NotFoundException('وضعیت نامعتبر است');
}
return this.prisma.comment.update({ where: { id }, data: { status } });
}
/** Legacy `POST comment` — post a staff reply to a comment. */
async create(data: {
product_id: string;
customer_id: string;
comment: string;
reply_to?: string;
}) {
return this.prisma.comment.create({
data: {
productId: data.product_id,
customerId: data.customer_id,
body: data.comment,
replyToId: data.reply_to ?? null,
// A staff reply is visible immediately; legacy did the same.
status: CommentStatus.APPROVED,
},
});
}
/** Legacy `DELETE comment/{id}`. */
async remove(id: string) {
const c = await this.prisma.comment.findUnique({
where: { id },
include: { replies: true },
});
if (!c) throw new NotFoundException('دیدگاه یافت نشد');
// Remove replies first so the self-relation does not block the delete.
await this.prisma.$transaction([
this.prisma.comment.deleteMany({ where: { replyToId: id } }),
this.prisma.comment.delete({ where: { id } }),
]);
return { success: true };
}
/** Farsi labels for the status dropdown, keyed by legacy's numeric codes. */
statusOptions() {
return Object.entries(STATUS_LABELS).map(([status, title]) => ({
value: CODE_BY_STATUS[status as CommentStatus],
title,
}));
}
}