diff --git a/src/app.module.ts b/src/app.module.ts index 11f5936..407468a 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -18,6 +18,7 @@ import { AnnouncementsModule } from "./modules/announcements/announcement.module import { AuthModule } from "./modules/auth/auth.module"; import { BusinessesModule } from "./modules/businesses/businesses.module"; import { CompaniesModule } from "./modules/companies/companies.module"; +import { CriticismModule } from "./modules/criticisms/criticisms.module"; import { IndustriesModule } from "./modules/industries/industries.module"; import { InvoicesModule } from "./modules/invoices/invoices.module"; import { NotificationModule } from "./modules/notifications/notifications.module"; @@ -55,6 +56,7 @@ import { UtilsModule } from "./modules/utils/utils.module"; InvoicesModule, BusinessesModule, AnnouncementsModule, + CriticismModule, ], // providers: [ // { diff --git a/src/modules/businesses/entities/business.entity.ts b/src/modules/businesses/entities/business.entity.ts index b775f2a..d777a5b 100644 --- a/src/modules/businesses/entities/business.entity.ts +++ b/src/modules/businesses/entities/business.entity.ts @@ -3,6 +3,7 @@ import { Cascade, Collection, Entity, EntityRepositoryType, OneToMany, Property, import { BaseEntity } from "../../../common/entities/base.entity"; import { Announcement } from "../../announcements/entities/announcement.entity"; import { Company } from "../../companies/entities/company.entity"; +import { Criticism } from "../../criticisms/entities/criticism.entity"; import { Industry } from "../../industries/entities/industry.entity"; import { Invoice } from "../../invoices/entities/invoice.entity"; import { TicketCategory } from "../../tickets/entities/ticket-category.entity"; @@ -39,5 +40,8 @@ export class Business extends BaseEntity { @OneToMany(() => Announcement, (announcement) => announcement.business) announcements = new Collection(this); + @OneToMany(() => Criticism, (criticism) => criticism.business) + criticisms = new Collection(this); + [EntityRepositoryType]?: BusinessRepository; } diff --git a/src/modules/criticisms/DTO/create-criticism.dto.ts b/src/modules/criticisms/DTO/create-criticism.dto.ts new file mode 100755 index 0000000..dbffc4b --- /dev/null +++ b/src/modules/criticisms/DTO/create-criticism.dto.ts @@ -0,0 +1,25 @@ +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() + @IsNotEmpty({ message: CriticismMessage.FILES_IS_REQUIRED }) + @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[]; +} diff --git a/src/modules/criticisms/DTO/search-criticism-query.dto.ts b/src/modules/criticisms/DTO/search-criticism-query.dto.ts new file mode 100755 index 0000000..10abcd1 --- /dev/null +++ b/src/modules/criticisms/DTO/search-criticism-query.dto.ts @@ -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; +} diff --git a/src/modules/criticisms/criticisms.controller.ts b/src/modules/criticisms/criticisms.controller.ts new file mode 100755 index 0000000..6932921 --- /dev/null +++ b/src/modules/criticisms/criticisms.controller.ts @@ -0,0 +1,43 @@ +import { Body, Controller, Delete, Get, Param, Post, Query, UseInterceptors } from "@nestjs/common"; +import { ApiOperation } 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 { AuthGuards } from "../../common/decorators/auth-guard.decorator"; +import { BusinessDec } from "../../common/decorators/business.decorator"; +import { UserDec } from "../../common/decorators/user.decorator"; +import { ParamDto } from "../../common/DTO/param.dto"; +import { BusinessInterceptor } from "../../core/interceptors/business.interceptor"; +import { Business } from "../businesses/entities/business.entity"; + +@Controller("criticisms") +@UseInterceptors(BusinessInterceptor) +@AuthGuards() +export class CriticismController { + constructor(private readonly criticismService: CriticismsService) {} + + @ApiOperation({ summary: "Create a new criticism" }) + @Post() + create(@Body() createDto: CreateCriticismDto, @UserDec("id") userId: string, @BusinessDec() business: Business) { + return this.criticismService.create(createDto, userId, business); + } + + @ApiOperation({ summary: "Get all Criticism (admin)" }) + @Get() + findAll(@Query() queryDto: SearchCriticismQueryDto, @BusinessDec("id") businessId: string) { + return this.criticismService.findAll(queryDto, businessId); + } + + @ApiOperation({ summary: "Get one Criticism with id (admin)" }) + @Get(":id") + fineOne(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.criticismService.findOneAndMarkAsRead(paramDto.id, businessId); + } + + @ApiOperation({ summary: "Delete one Criticism with id (admin)" }) + @Delete(":id") + delete(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { + return this.criticismService.delete(paramDto.id, businessId); + } +} diff --git a/src/modules/criticisms/criticisms.module.ts b/src/modules/criticisms/criticisms.module.ts new file mode 100755 index 0000000..c71d8bd --- /dev/null +++ b/src/modules/criticisms/criticisms.module.ts @@ -0,0 +1,17 @@ +import { MikroOrmModule } from "@mikro-orm/nestjs"; +import { Module } from "@nestjs/common"; + +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 { BusinessesModule } from "../businesses/businesses.module"; +import { UsersModule } from "../users/users.module"; + +@Module({ + imports: [MikroOrmModule.forFeature([Criticism, CriticismFile]), UsersModule, BusinessesModule], + providers: [CriticismsService], + controllers: [CriticismController], + exports: [CriticismsService], +}) +export class CriticismModule {} diff --git a/src/modules/criticisms/entities/criticism-file.entity.ts b/src/modules/criticisms/entities/criticism-file.entity.ts new file mode 100755 index 0000000..9ed4bb3 --- /dev/null +++ b/src/modules/criticisms/entities/criticism-file.entity.ts @@ -0,0 +1,16 @@ +import { Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/postgresql"; + +import { Criticism } from "./criticism.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { CriticismFileRepository } from "../repositories/criticisms-file.repository"; + +@Entity({ repository: () => CriticismFileRepository }) +export class CriticismFile extends BaseEntity { + @Property({ type: "varchar", length: 255 }) + fileUrl!: string; + + @ManyToOne(() => Criticism, { nullable: false, deleteRule: "cascade" }) + criticism!: Criticism; + + [EntityRepositoryType]?: CriticismFileRepository; +} diff --git a/src/modules/criticisms/entities/criticism.entity.ts b/src/modules/criticisms/entities/criticism.entity.ts new file mode 100755 index 0000000..81d4a7f --- /dev/null +++ b/src/modules/criticisms/entities/criticism.entity.ts @@ -0,0 +1,34 @@ +import { Cascade, Collection, Entity, EntityRepositoryType, Index, ManyToOne, OneToMany, Opt, Property } from "@mikro-orm/core"; + +import { CriticismFile } from "./criticism-file.entity"; +import { BaseEntity } from "../../../common/entities/base.entity"; +import { Business } from "../../businesses/entities/business.entity"; +import { User } from "../../users/entities/user.entity"; +import { CriticismRepository } from "../repositories/criticisms.repository"; + +@Entity({ repository: () => CriticismRepository }) +export class Criticism extends BaseEntity { + @Property({ type: "int", autoincrement: true }) + numericId!: number & Opt; + + @Index() + @Property({ type: "varchar", length: 150, nullable: false }) + title!: string; + + @Property({ type: "text", nullable: false }) + content!: string; + + @ManyToOne(() => User, { nullable: false, deleteRule: "cascade" }) + user!: User; + + @Property({ type: "boolean", default: false }) + isRead!: boolean & Opt; + + @OneToMany(() => CriticismFile, (criticismFile) => criticismFile.criticism, { eager: true, cascade: [Cascade.PERSIST, Cascade.REMOVE] }) + files = new Collection(this); + + @ManyToOne(() => Business, { nullable: false, deleteRule: "cascade" }) + business!: Business; + + [EntityRepositoryType]?: CriticismRepository; +} diff --git a/src/modules/criticisms/providers/criticisms.service.ts b/src/modules/criticisms/providers/criticisms.service.ts new file mode 100755 index 0000000..56444ab --- /dev/null +++ b/src/modules/criticisms/providers/criticisms.service.ts @@ -0,0 +1,99 @@ +import { EntityManager, QueryOrder } from "@mikro-orm/postgresql"; +import { BadRequestException, Injectable } from "@nestjs/common"; + +import { CommonMessage, CriticismMessage } from "../../../common/enums/message.enum"; +import { Business } from "../../businesses/entities/business.entity"; +import { UsersService } from "../../users/services/users.service"; +import { PaginationUtils } from "../../utils/providers/pagination.utils"; +import { CreateCriticismDto } from "../DTO/create-criticism.dto"; +import { SearchCriticismQueryDto } from "../DTO/search-criticism-query.dto"; +import { Criticism } from "../entities/criticism.entity"; +import { CriticismRepository } from "../repositories/criticisms.repository"; + +@Injectable() +export class CriticismsService { + constructor( + private readonly criticismRepository: CriticismRepository, + private readonly usersService: UsersService, + private readonly em: EntityManager, + ) {} + + async create(createDto: CreateCriticismDto, userId: string, business: Business) { + const em = this.em.fork(); + try { + em.begin(); + + const user = await this.usersService.findOneByIdWithEntityManager(userId, em); + const files = + createDto.files?.map((file) => { + return { fileUrl: file }; + }) || []; + const criticism = em.create(Criticism, { ...createDto, user, files, business }); + await em.persistAndFlush(criticism); + + await em.commit(); + return { + message: CommonMessage.CREATED, + criticism, + }; + } catch (error) { + await em.rollback(); + throw error; + } + } + + //##################################### ## + + async findAll(queryDto: SearchCriticismQueryDto, businessId: string) { + const { limit, skip } = PaginationUtils(queryDto); + + const queryBuilder = this.criticismRepository.createQueryBuilder("criticism"); + + queryBuilder.where({ business: { id: businessId } }); + + if (queryDto.since) { + queryBuilder.andWhere({ createdAt: { $gte: queryDto.since } }); + } + + if (queryDto.q) { + queryBuilder.andWhere({ title: { $ilike: `%${queryDto.q}%` } }); + queryBuilder.andWhere({ content: { $ilike: `%${queryDto.q}%` } }); + } + + queryBuilder.orderBy({ createdAt: QueryOrder.DESC }); + + const [criticisms, count] = await queryBuilder.offset(skip).limit(limit).getResultAndCount(); + + return { + criticisms, + count, + paginate: true, + }; + } + + //##################################### ## + + async findOneAndMarkAsRead(id: string, businessId: string) { + const criticism = await this.criticismRepository.findOne({ id, business: { id: businessId } }); + if (!criticism) throw new BadRequestException(CriticismMessage.NOT_FOUND); + criticism.isRead = true; + + await this.em.flush(); + return { + criticism, + }; + } + + //##################################### ## + + async delete(id: string, businessId: string) { + const criticism = await this.criticismRepository.findOne({ id, business: { id: businessId } }); + if (!criticism) throw new BadRequestException(CriticismMessage.NOT_FOUND); + + await this.em.removeAndFlush(criticism); + + return { + message: CommonMessage.DELETED, + }; + } +} diff --git a/src/modules/criticisms/repositories/criticisms-file.repository.ts b/src/modules/criticisms/repositories/criticisms-file.repository.ts new file mode 100755 index 0000000..0c48e2a --- /dev/null +++ b/src/modules/criticisms/repositories/criticisms-file.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { CriticismFile } from "../entities/criticism-file.entity"; + +export class CriticismFileRepository extends EntityRepository {} diff --git a/src/modules/criticisms/repositories/criticisms.repository.ts b/src/modules/criticisms/repositories/criticisms.repository.ts new file mode 100755 index 0000000..f339125 --- /dev/null +++ b/src/modules/criticisms/repositories/criticisms.repository.ts @@ -0,0 +1,5 @@ +import { EntityRepository } from "@mikro-orm/postgresql"; + +import { Criticism } from "../entities/criticism.entity"; + +export class CriticismRepository extends EntityRepository {} diff --git a/src/modules/invoices/entities/invoice-item.entity.ts b/src/modules/invoices/entities/invoice-item.entity.ts index 13190bb..e015703 100755 --- a/src/modules/invoices/entities/invoice-item.entity.ts +++ b/src/modules/invoices/entities/invoice-item.entity.ts @@ -1,4 +1,4 @@ -import { DecimalType, Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/postgresql"; +import { DecimalType, Entity, EntityRepositoryType, ManyToOne, Property } from "@mikro-orm/core"; import Decimal from "decimal.js"; import { Invoice } from "./invoice.entity"; diff --git a/src/modules/tickets/tickets.controller.ts b/src/modules/tickets/tickets.controller.ts index 140d776..75435ac 100755 --- a/src/modules/tickets/tickets.controller.ts +++ b/src/modules/tickets/tickets.controller.ts @@ -9,7 +9,6 @@ import { SearchTicketCategoryDto } from "./DTO/search-ticket-category.dto"; import { SearchTicketQueryDto } from "./DTO/search-ticket-query.dto"; import { UpdateTicketCategoryDto } from "./DTO/update-ticket-category.dto"; import { TicketsService } from "./providers/tickets.service"; -import { AdminRoute } from "../../common/decorators/admin.decorator"; import { AuthGuards } from "../../common/decorators/auth-guard.decorator"; import { BusinessDec } from "../../common/decorators/business.decorator"; import { UserDec } from "../../common/decorators/user.decorator"; @@ -51,14 +50,12 @@ export class TicketsController { } @ApiOperation({ summary: "Get ticket categories list (admin)" }) - @AdminRoute() @Get("categories-list") getCategoriesList(@Query() queryDto: SearchTicketCategoryDto, @BusinessDec("id") businessId: string) { return this.ticketsService.getCategoriesList(queryDto, businessId); } @ApiOperation({ summary: "toggle status of categories (admin)" }) - @AdminRoute() @HttpCode(HttpStatus.OK) @Post("category/toggle-status/:id") toggleCategoryStatus(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { @@ -80,7 +77,6 @@ export class TicketsController { } @ApiOperation({ summary: "Get all tickets (admin)" }) - @AdminRoute() @Get("admin") getAllTickets(@Query() queryDto: SearchTicketQueryDto, @UserDec("id") adminId: string, @BusinessDec("id") businessId: string) { return this.ticketsService.getTicketForAdmin(queryDto, adminId, businessId); @@ -122,14 +118,12 @@ export class TicketsController { } @ApiOperation({ summary: "open ticket by admin" }) - @AdminRoute() @Post(":id/open") openTicketByAdmin(@Param() paramDto: ParamDto, @BusinessDec("id") businessId: string) { return this.ticketsService.openTicketByAdmin(paramDto.id, businessId); } @ApiOperation({ summary: "referer ticket ==> admin route" }) - @AdminRoute() @Post(":id/refer") referTicket( @Param() paramDto: ParamDto,