This commit is contained in:
2026-02-22 12:50:49 +03:30
parent 520694e4b1
commit 2c4b4fdc56
16 changed files with 3815 additions and 3 deletions
File diff suppressed because it is too large Load Diff
+2
View File
@@ -21,6 +21,7 @@ import { RequestModule } from './modules/request/request.module';
import { InvoiceModule } from './modules/invoice/invoice.module';
import { OrderModule } from './modules/order/order.module';
import { LearningModule } from './modules/learnings/learning.module';
import { CriticismModule } from './modules/criticisms/criticisms.module';
@Module({
imports: [
@@ -50,6 +51,7 @@ import { LearningModule } from './modules/learnings/learning.module';
InvoiceModule,
OrderModule,
LearningModule,
CriticismModule,
],
controllers: [],
providers: [],
+11
View File
@@ -137,6 +137,17 @@ export const enum LearningMessage {
VIDEO_DURATION_INVALID = 'مدت زمان ویدیو نامعتبر است',
}
export const enum CriticismMessage {
NOT_FOUND = 'انتقاد مورد نظر یافت نشد',
TITLE_IS_REQUIRED = 'عنوان الزامی است',
TITLE_STRING = 'عنوان باید رشته باشد',
CONTENT_IS_REQUIRED = 'محتوا الزامی است',
CONTENT_IS_STRING = 'محتوا باید رشته باشد',
FILES_IS_REQUIRED = 'فایل‌ها الزامی است',
FILES_SHOULD_BE_URL = 'آدرس فایل باید URL معتبر باشد',
SEARCH_QUERY_STRING = 'رشته جستجو باید یک رشته باشد',
}
export const enum productMessage {
NOT_FOUND = 'کالایی با این مشخصات یافت نشد',
}
+4 -1
View File
@@ -43,6 +43,8 @@ export enum PermissionEnum {
MANAGE_PRINT = 'view_print',
MANAGE_LEARNINGS = 'manage_learnings',
MANAGE_CRITICISMS = 'manage_criticisms',
}
/**
@@ -77,5 +79,6 @@ export const PermissionTitles: Record<PermissionEnum, string> = {
[PermissionEnum.DELETE_ORDER]: "",
[PermissionEnum.CREATE_PRINT]: "ایجاد فرم چاپ برای سفارش",
[PermissionEnum.MANAGE_PRINT]: "مدیریت فرم چاپ",
[PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها"
[PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها",
[PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها"
};
+24
View File
@@ -0,0 +1,24 @@
import { ApiProperty } from "@nestjs/swagger";
import { IsNotEmpty, IsOptional, IsString, IsUrl } from "class-validator";
import { CriticismMessage } from "../../../common/enums/message.enum";
export class CreateCriticismDto {
@IsNotEmpty({ message: CriticismMessage.TITLE_IS_REQUIRED })
@IsString({ message: CriticismMessage.TITLE_STRING })
@ApiProperty({ type: String, description: "title of criticism", example: "انتقاد بابت مشکلات" })
title: string;
@IsNotEmpty({ message: CriticismMessage.CONTENT_IS_REQUIRED })
@IsString({ message: CriticismMessage.CONTENT_IS_STRING })
@ApiProperty({ type: String, description: "description of criticism", example: "متن انتقاد" })
content: string;
@IsOptional()
@IsUrl({ protocols: ['http', 'https'], require_protocol: true }, { message: CriticismMessage.FILES_SHOULD_BE_URL, each: true })
@ApiProperty({
description: "The ulr of the criticism files",
example: ["https://example.com/image1.png", "https://example.com/image2.png"],
})
files: string[];
}
+17
View File
@@ -0,0 +1,17 @@
import { ApiPropertyOptional } from "@nestjs/swagger";
import { IsDateString, IsOptional, IsString } from "class-validator";
import { PaginationDto } from "../../../common/DTO/pagination.dto";
import { CriticismMessage } from "../../../common/enums/message.enum";
export class SearchCriticismQueryDto extends PaginationDto {
@IsOptional()
@IsDateString()
@ApiPropertyOptional({ description: "since query", example: "2025-01-27" })
since?: string;
@IsOptional()
@IsString({ message: CriticismMessage.SEARCH_QUERY_STRING })
@ApiPropertyOptional({ description: "Search query", example: "search query" })
q?: string;
}
+49
View File
@@ -0,0 +1,49 @@
import { Body, Controller, Delete, Get, Param, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { CreateCriticismDto } from './DTO/create-criticism.dto';
import { SearchCriticismQueryDto } from './DTO/search-criticism-query.dto';
import { CriticismsService } from './providers/criticisms.service';
import { AuthGuard } from '../auth/guards/auth.guard';
import { AdminAuthGuard } from '../auth/guards/adminAuth.guard';
import { Permissions } from '../../common/decorators/permissions.decorator';
import { UserId } from '../../common/decorators';
import { PermissionEnum } from '../../common/enums/permission.enum';
@ApiTags('Criticism')
@ApiBearerAuth()
@Controller('criticisms')
export class CriticismController {
constructor(private readonly criticismService: CriticismsService) {}
@Post()
@UseGuards(AuthGuard)
@ApiOperation({ summary: 'Create a new criticism' })
create(@Body() createDto: CreateCriticismDto, @UserId() userId: string) {
return this.criticismService.create(createDto, userId);
}
@Get()
@UseGuards(AdminAuthGuard)
@Permissions(PermissionEnum.MANAGE_CRITICISMS)
@ApiOperation({ summary: 'Get all criticisms (admin only)' })
findAll(@Query() queryDto: SearchCriticismQueryDto) {
return this.criticismService.findAll(queryDto);
}
@Get(':id')
@UseGuards(AdminAuthGuard)
@Permissions(PermissionEnum.MANAGE_CRITICISMS)
@ApiOperation({ summary: 'Get one criticism by id (admin only)' })
findOne(@Param('id') id: string) {
return this.criticismService.findOneAndMarkAsRead(id);
}
@Delete(':id')
@UseGuards(AdminAuthGuard)
@Permissions(PermissionEnum.MANAGE_CRITICISMS)
@ApiOperation({ summary: 'Delete one criticism by id (admin only)' })
delete(@Param('id') id: string) {
return this.criticismService.delete(id);
}
}
+27
View File
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { MikroOrmModule } from '@mikro-orm/nestjs';
import { CriticismController } from './criticisms.controller';
import { CriticismFile } from './entities/criticism-file.entity';
import { Criticism } from './entities/criticism.entity';
import { CriticismsService } from './providers/criticisms.service';
import { CriticismFileRepository } from './repositories/criticisms-file.repository';
import { CriticismRepository } from './repositories/criticisms.repository';
import { UserModule } from '../user/user.module';
import { NotificationsModule } from '../notification/notifications.module';
import { AdminModule } from '../admin/admin.module';
import { JwtModule } from '@nestjs/jwt';
@Module({
imports: [
MikroOrmModule.forFeature([Criticism, CriticismFile]),
UserModule,
NotificationsModule,
AdminModule,
JwtModule
],
providers: [CriticismsService, CriticismRepository, CriticismFileRepository],
controllers: [CriticismController],
exports: [CriticismsService],
})
export class CriticismModule {}
+14
View File
@@ -0,0 +1,14 @@
import { Entity, ManyToOne, OptionalProps, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { Criticism } from './criticism.entity';
@Entity({ tableName: 'criticism_files' })
export class CriticismFile extends BaseEntity {
[OptionalProps]?: 'createdAt' | 'deletedAt';
@Property({ type: 'varchar', length: 255 })
fileUrl: string;
@ManyToOne(() => Criticism)
criticism: Criticism;
}
+24
View File
@@ -0,0 +1,24 @@
import { Cascade, Collection, Entity, ManyToOne, OneToMany, OptionalProps, Property } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { User } from '../../user/entities/user.entity';
import { CriticismFile } from './criticism-file.entity';
@Entity({ tableName: 'criticisms' })
export class Criticism extends BaseEntity {
[OptionalProps]?: 'createdAt' | 'deletedAt';
@Property({ type: 'varchar', length: 150 })
title: string;
@Property({ type: 'text' })
content: string;
@ManyToOne(() => User)
user: User;
@Property({ default: false })
isRead: boolean = false;
@OneToMany(() => CriticismFile, (criticismFile) => criticismFile.criticism, { cascade: [Cascade.ALL] })
files = new Collection<CriticismFile>(this);
}
+135
View File
@@ -0,0 +1,135 @@
import { BadRequestException, Injectable } from '@nestjs/common';
import { EntityManager } from '@mikro-orm/postgresql';
import { CommonMessage, CriticismMessage } from '../../../common/enums/message.enum';
import { NotificationQueueService } from '../../notification/services/notification-queue.service';
import { AdminRepository } from '../../admin/repositories/admin.repository';
import { PermissionEnum } from '../../../common/enums/permission.enum';
import { NotifEvent } from '../../notification/interfaces/notification.interface';
import { Notification } from '../../notification/entities/notification.entity';
import { CreateCriticismDto } from '../DTO/create-criticism.dto';
import { SearchCriticismQueryDto } from '../DTO/search-criticism-query.dto';
import { Criticism } from '../entities/criticism.entity';
import { CriticismFile } from '../entities/criticism-file.entity';
import { CriticismRepository } from '../repositories/criticisms.repository';
import { UserService } from '../../user/providers/user.service';
@Injectable()
export class CriticismsService {
constructor(
private readonly criticismRepository: CriticismRepository,
private readonly userService: UserService,
private readonly em: EntityManager,
private readonly adminRepository: AdminRepository,
private readonly notificationQueueService: NotificationQueueService,
) {}
async create(createDto: CreateCriticismDto, userId: string) {
return this.em.transactional(async (em) => {
const user = await this.userService.findOrFail(userId);
const criticism = em.create(Criticism, {
title: createDto.title,
content: createDto.content,
user,
isRead: false,
});
for (const fileUrl of createDto.files ?? []) {
const file = em.create(CriticismFile, { fileUrl, criticism });
criticism.files.add(file);
}
await em.persistAndFlush(criticism);
await this.notifyAdminsWithPermission(criticism, em);
return {
message: CommonMessage.CREATED,
criticism,
};
});
}
async findAll(queryDto: SearchCriticismQueryDto) {
const page = queryDto.page ?? 1;
const limit = queryDto.limit ?? 10;
const offset = (page - 1) * limit;
const where: Record<string, unknown> = {};
if (queryDto.since) {
where.createdAt = { $gte: new Date(queryDto.since) };
}
if (queryDto.q) {
where.title = { $like: `%${queryDto.q}%` };
}
const [criticisms, count] = await this.em.findAndCount(Criticism, where, {
orderBy: { createdAt: 'DESC' },
offset,
limit,
populate: ['user', 'files'],
});
return {
criticisms,
count,
paginate: true,
};
}
async findOneAndMarkAsRead(id: string) {
const criticism = await this.criticismRepository.findOne(
{ id },
{ populate: ['user', 'files'] },
);
if (!criticism) throw new BadRequestException(CriticismMessage.NOT_FOUND);
criticism.isRead = true;
await this.em.flush();
return {
criticism,
};
}
async delete(id: string) {
const criticism = await this.criticismRepository.findOne({ id });
if (!criticism) throw new BadRequestException(CriticismMessage.NOT_FOUND);
await this.em.removeAndFlush(criticism);
return {
message: CommonMessage.DELETED,
};
}
private async notifyAdminsWithPermission(criticism: Criticism, em: EntityManager) {
const admins = await this.adminRepository.findAdminsWithPermission(
PermissionEnum.MANAGE_CRITICISMS,
);
if (admins.length === 0) return;
const user = criticism.user;
const fullName = `${user.firstName ?? ''} ${user.lastName ?? ''}`.trim() || 'کاربر';
const body = `انتقاد جدید از ${fullName}: ${criticism.title}`;
const notifications = admins.map((admin) =>
em.create(Notification, {
admin,
title: NotifEvent.NEW_CRITICISM,
content: body,
}),
);
await em.persistAndFlush(notifications);
await this.notificationQueueService.addBulkInAppNotifications(
notifications.map((n) => ({
recipient: { adminId: n.admin!.id },
subject: NotifEvent.NEW_CRITICISM,
body,
notificationId: n.id,
})),
);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { CriticismFile } from '../entities/criticism-file.entity';
@Injectable()
export class CriticismFileRepository extends EntityRepository<CriticismFile> {
constructor(readonly em: EntityManager) {
super(em, CriticismFile);
}
}
@@ -0,0 +1,10 @@
import { Injectable } from '@nestjs/common';
import { EntityManager, EntityRepository } from '@mikro-orm/postgresql';
import { Criticism } from '../entities/criticism.entity';
@Injectable()
export class CriticismRepository extends EntityRepository<Criticism> {
constructor(readonly em: EntityManager) {
super(em, Criticism);
}
}
@@ -14,6 +14,7 @@ export enum NotifEvent {
NEW_ORDER = 'newOrder', // for chapkhane sms bere
DESIGNER_ASSIGN = 'designerAssign',
PAYMENT_SUCCESS = 'paymentSuccess',
NEW_CRITICISM = 'newCriticism',
}
export type recipientType = { userId: string } | { adminId: string };
+2 -1
View File
@@ -16,6 +16,7 @@ import { UserModule } from '../user/user.module';
import { UtilsModule } from '../util/utils.module';
import { ProductModule } from '../product/product.module';
import { TicketModule } from '../ticket/ticket.module';
import { OrderPrintRepository } from './repositories/order-print.repository';
@Module({
imports: [
@@ -32,7 +33,7 @@ import { TicketModule } from '../ticket/ticket.module';
],
controllers: [OrderController],
providers: [OrderService, OrderRepository, OrderListeners,
OrdersCrone,],
OrdersCrone, OrderPrintRepository],
exports: [OrderRepository, OrderService],
})
export class OrderModule { }
+5 -1
View File
@@ -1,6 +1,7 @@
import { Entity, Index, Property, OneToMany, Collection, Cascade, PrimaryKey, OptionalProps } from '@mikro-orm/core';
import { Entity, Index, Property, OneToMany, Collection, PrimaryKey, OptionalProps } from '@mikro-orm/core';
import { BaseEntity } from '../../../common/entities/base.entity';
import { LearningProgress } from '../../learnings/entities/learning-progress.entity';
import { Criticism } from '../../criticisms/entities/criticism.entity';
// import { Order } from 'src/modules/order/entities/order.entity';
import { normalizePhone } from '../../util/phone.util';
import { ulid } from 'ulid';
@@ -12,6 +13,9 @@ export class User extends BaseEntity {
@OneToMany(() => LearningProgress, (lp) => lp.user)
learningProgress = new Collection<LearningProgress>(this);
@OneToMany(() => Criticism, (c) => c.user)
criticisms = new Collection<Criticism>(this);
// @OneToMany(() => Order, order => order.user)
// orders = new Collection<Order>(this);