diff --git a/src/app.module.ts b/src/app.module.ts index b16a1c9..a3f74ea 100644 --- a/src/app.module.ts +++ b/src/app.module.ts @@ -23,6 +23,7 @@ import { OrderModule } from './modules/order/order.module'; import { LearningModule } from './modules/learnings/learning.module'; import { CriticismModule } from './modules/criticisms/criticisms.module'; import { AnnouncementModule } from './modules/announcements/announcement.module'; +import { TicketModule } from './modules/ticket/ticket.module'; @Module({ imports: [ @@ -54,6 +55,7 @@ import { AnnouncementModule } from './modules/announcements/announcement.module' LearningModule, CriticismModule, AnnouncementModule, + TicketModule, ], controllers: [], providers: [], diff --git a/src/common/enums/permission.enum.ts b/src/common/enums/permission.enum.ts index ad9d0ba..aa0dd69 100644 --- a/src/common/enums/permission.enum.ts +++ b/src/common/enums/permission.enum.ts @@ -48,6 +48,7 @@ export enum PermissionEnum { MANAGE_ANNOUNCEMENTS = 'manage_announcements', + MANAGE_TICKETS = 'manage_tickets', // Notif Permsissions NEW_REQUEST_NOTIFICATION='new_request_notification', @@ -90,6 +91,7 @@ export const PermissionTitles: Record = { [PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزش‌ها", [PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها", [PermissionEnum.MANAGE_ANNOUNCEMENTS]: "مدیریت اطلاعیه‌ها", + [PermissionEnum.MANAGE_TICKETS]: "مدیریت تیکت‌ها", [PermissionEnum.NEW_REQUEST_NOTIFICATION]: "", [PermissionEnum.NEW_PAYMENT_NOTIFICATION]: "" }; diff --git a/src/modules/notification/controllers/notifications.controller.ts b/src/modules/notification/controllers/notifications.controller.ts index 78d53e9..ad5efc1 100644 --- a/src/modules/notification/controllers/notifications.controller.ts +++ b/src/modules/notification/controllers/notifications.controller.ts @@ -1,171 +1,124 @@ -import { Controller, Get, Body, Param, UseGuards, Query, Patch, Put, Post } from '@nestjs/common'; -import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery, ApiHeader } from '@nestjs/swagger'; +import { Controller, Get, Param, UseGuards, Query, Put } from '@nestjs/common'; +import { ApiTags, ApiOperation, ApiBearerAuth, ApiParam, ApiQuery } from '@nestjs/swagger'; import { NotificationService } from '../services/notification.service'; import { AuthGuard } from '../../auth/guards/auth.guard'; import { UserId } from '../../../common/decorators/user-id.decorator'; import { AdminAuthGuard } from '../../auth/guards/adminAuth.guard'; import { AdminId } from 'src/common/decorators/admin-id.decorator'; import { NotificationMessage } from 'src/common/enums/message.enum'; -import { Permissions } from 'src/common/decorators/permissions.decorator'; -import { PermissionEnum } from 'src/common/enums/permission.enum'; +import { SmsLogRepository } from '../repositories/sms-log.repository'; @ApiTags('notifications') +@ApiBearerAuth() @Controller() export class NotificationsController { constructor( private readonly notificationService: NotificationService, + private readonly smsLogRepository: SmsLogRepository, ) { } - // @UseGuards(AuthGuard) - // @ApiBearerAuth() - // @Get('public/notifications') - // @ApiOperation({ summary: 'Get user restaurant notifications' }) + @Get('public/notifications') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get user notifications' }) + @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + description: 'Cursor for pagination (ID of the last item from previous page)', + }) + @ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' }) + async getUserNotifications( + @UserId() userId: string, + @Query('limit') limit?: number, + @Query('cursor') cursor?: string, + @Query('status') status?: 'seen' | 'unseen', + ) { + return await this.notificationService.findUserNotifications( + userId, + limit ? parseInt(limit.toString(), 10) : 50, + cursor, + status, + ); + } - // @ApiQuery({ name: 'limit', required: false, type: Number, description: 'Number of items to return (default: 50)' }) - // @ApiQuery({ - // name: 'cursor', - // required: false, - // type: String, - // description: 'Cursor for pagination (ID of the last item from previous page)', - // }) - // @ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' }) - // async getUserNotifications( - // @UserId() userId: string, - // @Query('limit') limit?: number, - // @Query('cursor') cursor?: string, - // @Query('status') status?: 'seen' | 'unseen', - // ) { - // return await this.notificationService.findByUserAndRestaurant( - // userId, - // limit ? parseInt(limit.toString(), 10) : 50, - // cursor, - // status, - // ); - // } + @UseGuards(AuthGuard) + @Get('public/notifications/unseen-count') + @ApiOperation({ summary: 'Get unseen notifications count for user' }) + async getUserUnseenCount(@UserId() userId: string) { + const count = await this.notificationService.countUnseenByUser(userId); + return { count }; + } - // @UseGuards(AuthGuard) - // @ApiBearerAuth() - // @Get('public/notifications/unseen-count') - // @ApiOperation({ summary: 'Get unseen notifications count for user' }) + @UseGuards(AuthGuard) + @Put('public/notifications/:id') + @ApiOperation({ summary: 'Read a notification ' }) + @ApiParam({ name: 'id', description: 'Notification ID' }) + async readNotificationUser(@Param('id') id: string, @UserId() userId: string) { + await this.notificationService.readNotificationAsUser(id, userId); + return { message: NotificationMessage.READ_SUCCESS }; + } - // async getUserUnseenCount(@UserId() userId: string,) { - // const count = await this.notificationService.countUnseenByUserAndRestaurant(userId,); - // return { count }; - // } + @UseGuards(AuthGuard) + @Put('public/notifications/read/all') + @ApiOperation({ summary: 'Read all notification ' }) + async readAllNotificationUser(@UserId() userId: string) { + await this.notificationService.readAllNotifsAsUser(userId); + return { message: NotificationMessage.READ_SUCCESS }; + } + /* ***************** Admin Endpoints ***************** */ - // @UseGuards(AuthGuard) - // @ApiBearerAuth() - // @Put('public/notifications/:id') - // @ApiOperation({ summary: 'Read a notification ' }) - // @ApiParam({ name: 'id', description: 'Notification ID' }) - // async readNotificationUser(, @Param('id') id: string, @UserId() userId: string) { - // await this.notificationService.readNotificationAsUser(id, userId,); - // return { message: NotificationMessage.READ_SUCCESS }; - // } + @UseGuards(AdminAuthGuard) + @Get('admin/notifications') + @ApiOperation({ summary: 'Get Admin notifications' }) + @ApiQuery({ name: 'limit', required: false, type: Number }) + @ApiQuery({ + name: 'cursor', + required: false, + type: String, + description: 'Cursor for pagination (ID of the last item from previous page)', + }) + @ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' }) + async getAdminNotifications( + @AdminId() adminId: string, + @Query('limit') limit?: number, + @Query('cursor') cursor?: string, + @Query('status') status?: 'seen' | 'unseen', + ) { + const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50; + return await this.notificationService.findAdminNotifications(adminId, parsedLimit, cursor, status); + } - // @UseGuards(AuthGuard) - // @ApiBearerAuth() - // @Put('public/notifications/read/all') - // @ApiOperation({ summary: 'Read all notification ' }) - // async readAllNotificationUser(, @UserId() userId: string) { - // await this.notificationService.readAllNotifsAsUser(userId,); - // return { message: NotificationMessage.READ_SUCCESS }; - // } - // /* ***************** Admin Endpoints ***************** */ + @UseGuards(AdminAuthGuard) + @Get('admin/notifications/unseen-count') + @ApiOperation({ summary: 'Get unseen notifications count for admin' }) + async getAdminUnseenCount(@AdminId() adminId: string) { + const count = await this.notificationService.countUnseenByAdmin(adminId); + return { count }; + } - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Get('admin/notifications') - // @ApiOperation({ summary: 'Get Admin notifications' }) - // @ApiQuery({ name: 'limit', required: false, type: Number }) - // @ApiQuery({ - // name: 'cursor', - // required: false, - // type: String, - // description: 'Cursor for pagination (ID of the last item from previous page)', - // }) - // @ApiQuery({ name: 'status', required: false, enum: ['seen', 'unseen'], description: 'Filter by notification status' }) - // async getAdminNotifications( - // @AdminId() adminId: string, - // , - // @Query('limit') limit?: number, - // @Query('cursor') cursor?: string, - // @Query('status') status?: 'seen' | 'unseen', - // ) { - // const parsedLimit = limit ? parseInt(limit.toString(), 10) : 50; - // return await this.notificationService.findByRestaurant( adminId, parsedLimit, cursor, status); - // } + @UseGuards(AdminAuthGuard) + @Put('admin/notifications/:id') + @ApiOperation({ summary: 'Read a notification ' }) + @ApiParam({ name: 'id', description: 'Notification ID' }) + async readNotificationAdmin(@AdminId() adminId: string, @Param('id') id: string) { + await this.notificationService.readNotificationAdmin(id, adminId); + return { message: NotificationMessage.READ_SUCCESS }; + } - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Get('admin/notifications/unseen-count') - // @ApiOperation({ summary: 'Get unseen notifications count for admin' }) - // async getAdminUnseenCount(@AdminId() adminId: string,) { - // const count = await this.notificationService.countUnseenByRestaurant(adminId,); - // return { count }; - // } - - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Put('admin/notifications/:id') - // @ApiOperation({ summary: 'Read a notification ' }) - // @ApiParam({ name: 'id', description: 'Notification ID' }) - // async readNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) { - // await this.notificationService.readNotificationAdmin(id, adminId,); - // return { message: NotificationMessage.READ_SUCCESS }; - // } - - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Put('admin/notifications/read/all') - // @ApiOperation({ summary: 'Read all notification ' }) - // async readAllNotificationAdmin(, @AdminId() adminId: string, @Param('id') id: string) { - // await this.notificationService.readAllNotifsAsAdmin(adminId,); - // return { message: NotificationMessage.READ_SUCCESS }; - // } - - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Permissions(Permission.MANAGE_SETTINGS) - // @Get('admin/notification-preferences') - // @ApiOperation({ summary: 'Get all notification preferences for a restaurant' }) - // async getPreferences() { - // return this.preferenceService.findByRestaurant(); - // } - - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Permissions(Permission.MANAGE_SETTINGS) - // @Patch('admin/notification-preferences/:id') - // @ApiOperation({ summary: 'Update notification channels' }) - // @ApiParam({ name: 'id', description: 'Notification preference ID' }) - // async updatePreference( - // , - // @Param('id') preferenceId: string, - // @Body() dto: UpdatePreferenceDto, - // ) { - // return this.preferenceService.updatePreference(preferenceId, dto); - // } - - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Permissions(Permission.MANAGE_SETTINGS) - // @Post('admin/notification-preferences') - // @ApiOperation({ summary: 'Create notification preference' }) - // async createPreference( - // , - // @Body() dto: CreatePreferenceDto, - // ) { - // return this.preferenceService.create(dto); - // } - - // @UseGuards(AdminAuthGuard) - // @ApiBearerAuth() - // @Get('admin/notifications/sms-usage') - // @ApiOperation({ summary: 'Get SMS usage for my restaurant' }) - // async getRestaurantSmsUsage() { - // const smsCount = await this.notificationService.getSmsCountBy(); - // return { smsCount }; - // } + @UseGuards(AdminAuthGuard) + @Put('admin/notifications/read/all') + @ApiOperation({ summary: 'Read all notification ' }) + async readAllNotificationAdmin(@AdminId() adminId: string) { + await this.notificationService.readAllNotifsAsAdmin(adminId); + return { message: NotificationMessage.READ_SUCCESS }; + } + @UseGuards(AdminAuthGuard) + @Get('admin/notifications/sms-usage') + @ApiOperation({ summary: 'Get SMS usage' }) + async getRestaurantSmsUsage() { + const smsCount = await this.smsLogRepository.getTotalSmsCount(); + return { smsCount }; + } } diff --git a/src/modules/notification/notifications.module.ts b/src/modules/notification/notifications.module.ts index c99642d..989b253 100644 --- a/src/modules/notification/notifications.module.ts +++ b/src/modules/notification/notifications.module.ts @@ -64,6 +64,7 @@ import { SmsListeners } from './listeners/sms.listeners'; SmsLogRepository, SmsListeners, ], - exports: [NotificationService, NotificationQueueService, PushNotificationService, SmsService], + exports: [NotificationService, NotificationQueueService, + PushNotificationService, SmsService, NotificationsGateway], }) export class NotificationsModule { } diff --git a/src/modules/ticket/dto/create-ticket-user.dto.ts b/src/modules/ticket/dto/create-ticket-user.dto.ts new file mode 100644 index 0000000..47b56ae --- /dev/null +++ b/src/modules/ticket/dto/create-ticket-user.dto.ts @@ -0,0 +1,39 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsNotEmpty, IsOptional, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IAttachment } from 'src/modules/order/interface/order.interface'; + +class AttachmentDto implements IAttachment { + @IsString() + type: string; + + @IsString() + url: string; +} + +export class CreateTicketUserDto { + @ApiPropertyOptional({ description: 'Parent ticket ID for reply/thread' }) + @IsOptional() + @IsString() + parentId?: string; + + @ApiPropertyOptional({ description: 'Ticket subject' }) + @IsOptional() + @IsString() + subject?: string; + + @ApiProperty({ description: 'Ticket content', example: 'متن تیکت' }) + @IsNotEmpty() + @IsString() + content: string; + + @ApiPropertyOptional({ + description: 'Attachments', + type: [AttachmentDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AttachmentDto) + attachments?: IAttachment[]; +} diff --git a/src/modules/ticket/dto/create-ticket.dto.ts b/src/modules/ticket/dto/create-ticket.dto.ts new file mode 100644 index 0000000..314fe7e --- /dev/null +++ b/src/modules/ticket/dto/create-ticket.dto.ts @@ -0,0 +1,61 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { + IsString, + IsNotEmpty, + IsOptional, + IsEnum, + IsArray, + ValidateNested, +} from 'class-validator'; +import { Type } from 'class-transformer'; +import { TicketStatusEnum } from '../enum/status.enum'; +import { IAttachment } from 'src/modules/order/interface/order.interface'; + +class AttachmentDto implements IAttachment { + @IsString() + type: string; + + @IsString() + url: string; +} + +export class CreateTicketDto { + @ApiPropertyOptional({ description: 'Parent ticket ID for reply/thread' }) + @IsOptional() + @IsString() + parentId?: string; + + @ApiPropertyOptional({ description: 'Ticket subject' }) + @IsOptional() + @IsString() + subject?: string; + + @ApiProperty({ description: 'Ticket content', example: 'متن تیکت' }) + @IsNotEmpty() + @IsString() + content: string; + + @ApiProperty({ + description: 'Ticket status', + enum: TicketStatusEnum, + default: TicketStatusEnum.OPEN, + }) + @IsEnum(TicketStatusEnum) + status: TicketStatusEnum = TicketStatusEnum.OPEN; + + @ApiPropertyOptional({ + description: 'Attachments', + type: [AttachmentDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AttachmentDto) + attachments?: IAttachment[]; + + + @ApiPropertyOptional({ description: 'User ID who created/owns the ticket' }) + @IsOptional() + @IsString() + userId?: string; +} diff --git a/src/modules/ticket/dto/search-ticket-query.dto.ts b/src/modules/ticket/dto/search-ticket-query.dto.ts new file mode 100644 index 0000000..4e0d29b --- /dev/null +++ b/src/modules/ticket/dto/search-ticket-query.dto.ts @@ -0,0 +1,83 @@ +import { + IsOptional, + IsString, + IsNumber, + Min, + IsIn, + IsEnum, + IsDateString, +} from 'class-validator'; +import { Type, Transform } from 'class-transformer'; +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { TicketStatusEnum } from '../enum/status.enum'; + +const sortOrderOptions = ['asc', 'desc'] as const; +type SortOrder = (typeof sortOrderOptions)[number]; + +export class SearchTicketQueryDto { + @ApiPropertyOptional({ default: 1, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + page: number = 1; + + @ApiPropertyOptional({ default: 10, minimum: 1 }) + @IsOptional() + @Type(() => Number) + @IsNumber() + @Min(1) + limit: number = 10; + + @ApiPropertyOptional({ + description: 'Filter by ticket statuses', + enum: TicketStatusEnum, + isArray: true, + }) + @IsOptional() + @Transform(({ value }: { value: unknown }) => + Array.isArray(value) ? value : typeof value === 'string' ? value.split(',') : value, + ) + @IsEnum(TicketStatusEnum, { each: true }) + statuses?: TicketStatusEnum[]; + + @ApiPropertyOptional({ description: 'Filter by parent ticket (null for root tickets)' }) + @IsOptional() + @IsString() + parentId?: string; + + @ApiPropertyOptional({ description: 'Filter by admin ID' }) + @IsOptional() + @IsString() + adminId?: string; + + @ApiPropertyOptional({ description: 'Filter by user ID' }) + @IsOptional() + @IsString() + userId?: string; + + @ApiPropertyOptional({ description: 'Search in subject and content' }) + @IsOptional() + @IsString() + search?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + from?: string; + + @ApiPropertyOptional({ format: 'date-time' }) + @IsOptional() + @IsDateString() + to?: string; + + @ApiPropertyOptional({ default: 'createdAt' }) + @IsOptional() + @IsString() + orderBy: string = 'createdAt'; + + @ApiPropertyOptional({ enum: sortOrderOptions, default: 'desc' }) + @IsOptional() + @IsIn(sortOrderOptions) + order: SortOrder = 'desc'; +} diff --git a/src/modules/ticket/dto/update-ticket-user.dto.ts b/src/modules/ticket/dto/update-ticket-user.dto.ts new file mode 100644 index 0000000..247929a --- /dev/null +++ b/src/modules/ticket/dto/update-ticket-user.dto.ts @@ -0,0 +1,34 @@ +import { ApiPropertyOptional } from '@nestjs/swagger'; +import { IsString, IsOptional, IsArray, ValidateNested } from 'class-validator'; +import { Type } from 'class-transformer'; +import { IAttachment } from 'src/modules/order/interface/order.interface'; + +class AttachmentDto implements IAttachment { + @IsString() + type: string; + + @IsString() + url: string; +} + +export class UpdateTicketUserDto { + @ApiPropertyOptional({ description: 'Ticket subject' }) + @IsOptional() + @IsString() + subject?: string; + + @ApiPropertyOptional({ description: 'Ticket content' }) + @IsOptional() + @IsString() + content?: string; + + @ApiPropertyOptional({ + description: 'Attachments', + type: [AttachmentDto], + }) + @IsOptional() + @IsArray() + @ValidateNested({ each: true }) + @Type(() => AttachmentDto) + attachments?: IAttachment[]; +} diff --git a/src/modules/ticket/dto/update-ticket.dto.ts b/src/modules/ticket/dto/update-ticket.dto.ts new file mode 100644 index 0000000..5ce0530 --- /dev/null +++ b/src/modules/ticket/dto/update-ticket.dto.ts @@ -0,0 +1,4 @@ +import { PartialType } from '@nestjs/swagger'; +import { CreateTicketDto } from './create-ticket.dto'; + +export class UpdateTicketDto extends PartialType(CreateTicketDto) {} diff --git a/src/modules/ticket/entities/ticket.entity.ts b/src/modules/ticket/entities/ticket.entity.ts new file mode 100644 index 0000000..bc0ffd1 --- /dev/null +++ b/src/modules/ticket/entities/ticket.entity.ts @@ -0,0 +1,36 @@ +import { Collection, Entity, Enum, ManyToOne, OneToMany, OptionalProps, Property } from '@mikro-orm/core'; +import { BaseEntity } from 'src/common/entities/base.entity'; +import { TicketStatusEnum } from '../enum/status.enum'; +import { IAttachment } from 'src/modules/order/interface/order.interface'; +import { Admin } from 'src/modules/admin/entities/admin.entity'; +import { User } from 'src/modules/user/entities/user.entity'; + +@Entity({ tableName: 'tickets' }) +export class Ticket extends BaseEntity { + [OptionalProps]?: 'createdAt' | 'deletedAt' + + @ManyToOne(() => Ticket, { nullable: true }) + parent?: Ticket | null; + + @OneToMany(() => Ticket, (ticket) => ticket.parent) + children = new Collection(this) + + @Property({ nullable: true }) + subject?: string | null; + + @Property({ nullable: true }) + content: string; + + @Enum(() => TicketStatusEnum) + status: TicketStatusEnum; + + @Property({ nullable: true }) + attachments?: IAttachment[]; + + @ManyToOne(() => Admin, { nullable: true }) + admin?: Admin | null; + + @ManyToOne(() => User, { nullable: true }) + user?: User | null; + +} diff --git a/src/modules/ticket/enum/status.enum.ts b/src/modules/ticket/enum/status.enum.ts new file mode 100644 index 0000000..895d496 --- /dev/null +++ b/src/modules/ticket/enum/status.enum.ts @@ -0,0 +1,6 @@ +export enum TicketStatusEnum { + OPEN = 'open', + CLOSED = 'closed', + PENDING = 'pending', + IN_PROGRESS = 'in_progress', +} \ No newline at end of file diff --git a/src/modules/ticket/repositories/ticket.repository.ts b/src/modules/ticket/repositories/ticket.repository.ts new file mode 100644 index 0000000..ce26008 --- /dev/null +++ b/src/modules/ticket/repositories/ticket.repository.ts @@ -0,0 +1,102 @@ +import { Injectable } from '@nestjs/common'; +import { EntityManager, EntityRepository } from '@mikro-orm/postgresql'; +import { FilterQuery } from '@mikro-orm/core'; +import { Ticket } from '../entities/ticket.entity'; +import { PaginatedResult } from 'src/common/interfaces/pagination.interface'; +import { SearchTicketQueryDto } from '../dto/search-ticket-query.dto'; +import { TicketStatusEnum } from '../enum/status.enum'; + +@Injectable() +export class TicketRepository extends EntityRepository { + constructor(readonly em: EntityManager) { + super(em, Ticket); + } + + async findAllPaginated(dto: SearchTicketQueryDto): Promise> { + const { + page = 1, + limit = 10, + statuses, + parentId, + adminId, + userId, + search, + orderBy = 'createdAt', + order = 'desc', + from, + to, + } = dto; + + const offset = (page - 1) * limit; + + const where: FilterQuery = {}; + + if (parentId !== undefined) { + if (parentId === null || parentId === '') { + where.parent = null; + } else { + where.parent = { id: parentId }; + } + } + + if (adminId !== undefined) { + if (adminId === null || adminId === '') { + where.admin = null; + } else { + where.admin = { id: adminId }; + } + } + + if (userId !== undefined) { + if (userId === null || userId === '') { + where.user = null; + } else { + where.user = { id: userId }; + } + } + + if (statuses?.length) { + const normalizedStatuses = Array.isArray(statuses) ? statuses : [statuses]; + const validStatuses = normalizedStatuses.filter((s): s is TicketStatusEnum => !!s); + if (validStatuses.length === 1) { + where.status = validStatuses[0]; + } else if (validStatuses.length > 1) { + where.status = { $in: validStatuses }; + } + } + + if (from || to) { + where.createdAt = {}; + if (from) where.createdAt.$gte = new Date(from); + if (to) where.createdAt.$lte = new Date(to); + } + + if (search) { + const searchPattern = `%${search}%`; + where.$or = [ + { subject: { $ilike: searchPattern } }, + { content: { $ilike: searchPattern } }, + ]; + } + + const [data, total] = await this.findAndCount(where, { + limit, + offset, + orderBy: { [orderBy]: order.toLowerCase() as 'asc' | 'desc' }, + populate: ['parent', 'children', 'admin', 'user'], + filters: ['notDeleted'], + }); + + const totalPages = Math.ceil(total / limit); + + return { + data, + meta: { + total, + page, + limit, + totalPages, + }, + }; + } +} diff --git a/src/modules/ticket/ticket.controller.ts b/src/modules/ticket/ticket.controller.ts new file mode 100644 index 0000000..a6d2d0e --- /dev/null +++ b/src/modules/ticket/ticket.controller.ts @@ -0,0 +1,107 @@ +import { + Controller, + Get, + Post, + Body, + Patch, + Param, + Delete, + Query, + UseGuards, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; +import { TicketService } from './ticket.service'; +import { CreateTicketDto } from './dto/create-ticket.dto'; +import { UpdateTicketDto } from './dto/update-ticket.dto'; +import { CreateTicketUserDto } from './dto/create-ticket-user.dto'; +import { UpdateTicketUserDto } from './dto/update-ticket-user.dto'; +import { SearchTicketQueryDto } from './dto/search-ticket-query.dto'; +import { AdminAuthGuard } from '../auth/guards/adminAuth.guard'; +import { AuthGuard } from '../auth/guards/auth.guard'; +import { Permissions } from 'src/common/decorators/permissions.decorator'; +import { PermissionEnum } from 'src/common/enums/permission.enum'; +import { UserId } from 'src/common/decorators/user-id.decorator'; +import { AdminId } from 'src/common/decorators/admin-id.decorator'; + +@ApiTags('Ticket') +@ApiBearerAuth() +@Controller() +export class TicketController { + constructor(private readonly ticketService: TicketService) { } + + // ================== User routes =================== + + @Post('public/tickets') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Create a ticket (as user)' }) + createAsUser(@Body() createTicketUserDto: CreateTicketUserDto, @UserId() userId: string) { + return this.ticketService.createAsUser(createTicketUserDto, userId); + } + + @Get('public/tickets') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get my tickets with pagination' }) + findMyTickets(@Query() queryDto: SearchTicketQueryDto, @UserId() userId: string) { + return this.ticketService.findMyTickets(userId, queryDto); + } + + @Get('public/tickets/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Get one of my tickets by id' }) + findOneAsUser(@Param('id') id: string, @UserId() userId: string) { + return this.ticketService.findOneAsUser(id, userId); + } + + @Patch('public/tickets/:id') + @UseGuards(AuthGuard) + @ApiOperation({ summary: 'Update a ticket (as user, own tickets only)' }) + updateAsUser( + @Param('id') id: string, + @Body() updateTicketUserDto: UpdateTicketUserDto, + @UserId() userId: string, + ) { + return this.ticketService.updateAsUser(id, updateTicketUserDto, userId); + } + + // ================== Admin routes =================== + + @Post('admin/tickets') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_TICKETS) + @ApiOperation({ summary: 'Create a ticket (as admin)' }) + createAsAdmin(@Body() dto: CreateTicketDto, @AdminId() adminId: string) { + return this.ticketService.createAsAdmin(dto, adminId); + } + + @Get('admin/tickets') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_TICKETS) + @ApiOperation({ summary: 'Get all tickets with pagination' }) + findAllAsAdmin(@Query() queryDto: SearchTicketQueryDto) { + return this.ticketService.findAll(queryDto); + } + + @Get('admin/tickets/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_TICKETS) + @ApiOperation({ summary: 'Get one ticket by id' }) + findOneAsAdmin(@Param('id') id: string) { + return this.ticketService.findOne(id); + } + + @Patch('admin/tickets/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_TICKETS) + @ApiOperation({ summary: 'Update a ticket (as admin)' }) + updateAsAdmin(@Param('id') id: string, @Body() updateTicketDto: UpdateTicketDto) { + return this.ticketService.update(id, updateTicketDto); + } + + @Delete('admin/tickets/:id') + @UseGuards(AdminAuthGuard) + @Permissions(PermissionEnum.MANAGE_TICKETS) + @ApiOperation({ summary: 'Soft delete a ticket' }) + remove(@Param('id') id: string) { + return this.ticketService.remove(id); + } +} diff --git a/src/modules/ticket/ticket.module.ts b/src/modules/ticket/ticket.module.ts new file mode 100644 index 0000000..d16372a --- /dev/null +++ b/src/modules/ticket/ticket.module.ts @@ -0,0 +1,16 @@ +import { Module } from '@nestjs/common'; +import { MikroOrmModule } from '@mikro-orm/nestjs'; +import { TicketService } from './ticket.service'; +import { TicketController } from './ticket.controller'; +import { Ticket } from './entities/ticket.entity'; +import { TicketRepository } from './repositories/ticket.repository'; +import { AuthModule } from '../auth/auth.module'; +import { JwtModule } from '@nestjs/jwt'; + +@Module({ + imports: [MikroOrmModule.forFeature([Ticket]), AuthModule,JwtModule], + controllers: [TicketController], + providers: [TicketService, TicketRepository], + exports: [TicketService], +}) +export class TicketModule {} diff --git a/src/modules/ticket/ticket.service.ts b/src/modules/ticket/ticket.service.ts new file mode 100644 index 0000000..bfd4487 --- /dev/null +++ b/src/modules/ticket/ticket.service.ts @@ -0,0 +1,177 @@ +import { Injectable, NotFoundException, ForbiddenException } from '@nestjs/common'; +import { EntityManager } from '@mikro-orm/postgresql'; +import { CreateTicketDto } from './dto/create-ticket.dto'; +import { UpdateTicketDto } from './dto/update-ticket.dto'; +import { CreateTicketUserDto } from './dto/create-ticket-user.dto'; +import { UpdateTicketUserDto } from './dto/update-ticket-user.dto'; +import { SearchTicketQueryDto } from './dto/search-ticket-query.dto'; +import { Ticket } from './entities/ticket.entity'; +import { TicketRepository } from './repositories/ticket.repository'; +import { Admin } from 'src/modules/admin/entities/admin.entity'; +import { User } from 'src/modules/user/entities/user.entity'; +import { TicketStatusEnum } from './enum/status.enum'; + +@Injectable() +export class TicketService { + constructor( + private readonly em: EntityManager, + private readonly ticketRepository: TicketRepository, + ) { } + + async createAsAdmin(createTicketDto: CreateTicketDto, adminId: string) { + const { parentId, userId, ...rest } = createTicketDto; + + let parent: Ticket | null = null; + if (parentId) { + parent = await this.ticketRepository.findOne({ id: parentId }); + if (!parent) { + throw new NotFoundException('Parent ticket not found'); + } + } + + let admin: Admin | null = null; + if (adminId) { + admin = await this.em.findOne(Admin, { id: adminId }); + if (!admin) { + throw new NotFoundException('Admin not found'); + } + } + + let user: User | null = null; + if (userId) { + user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException('User not found'); + } + } + + const ticket = this.em.create(Ticket, { + ...rest, + parent: parent ?? undefined, + admin: admin ?? undefined, + user: user ?? undefined, + }); + await this.em.persistAndFlush(ticket); + return ticket; + } + + async createAsUser(createTicketUserDto: CreateTicketUserDto, userId: string) { + const user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException('User not found'); + } + + const { parentId, ...rest } = createTicketUserDto; + + let parent: Ticket | null = null; + if (parentId) { + parent = await this.ticketRepository.findOne({ id: parentId }, { filters: ['notDeleted'] }); + if (!parent) { + throw new NotFoundException('Parent ticket not found'); + } + if (parent.user?.id !== userId) { + throw new ForbiddenException('You can only reply to your own tickets'); + } + } + + const ticket = this.em.create(Ticket, { + ...rest, + parent: parent ?? undefined, + user, + status: TicketStatusEnum.OPEN, + }); + await this.em.persistAndFlush(ticket); + return ticket; + } + + findAll(dto: SearchTicketQueryDto) { + return this.ticketRepository.findAllPaginated(dto); + } + + findMyTickets(userId: string, dto: SearchTicketQueryDto) { + return this.ticketRepository.findAllPaginated({ ...dto, userId }); + } + + async findOneAsUser(id: string, userId: string) { + const ticket = await this.ticketRepository.findOne( + { id, user: { id: userId } }, + { populate: ['parent', 'children', 'admin', 'user'], filters: ['notDeleted'] }, + ); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + return ticket; + } + + async findOne(id: string) { + const ticket = await this.ticketRepository.findOne( + { id }, + { populate: ['parent', 'children', 'admin', 'user'], filters: ['notDeleted'] }, + ); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + return ticket; + } + + async update(id: string, updateTicketDto: UpdateTicketDto) { + const ticket = await this.ticketRepository.findOne({ id }, { filters: ['notDeleted'] }); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + + const { parentId, userId, ...rest } = updateTicketDto; + + if (parentId !== undefined) { + if (parentId === null || parentId === '') { + ticket.parent = undefined; + } else { + const parent = await this.ticketRepository.findOne({ id: parentId }); + if (!parent) { + throw new NotFoundException('Parent ticket not found'); + } + ticket.parent = parent; + } + } + + + if (userId !== undefined) { + if (userId === null || userId === '') { + ticket.user = undefined; + } else { + const user = await this.em.findOne(User, { id: userId }); + if (!user) { + throw new NotFoundException('User not found'); + } + ticket.user = user; + } + } + + this.em.assign(ticket, rest); + await this.em.flush(); + return ticket; + } + + async updateAsUser(id: string, updateTicketUserDto: UpdateTicketUserDto, userId: string) { + const ticket = await this.ticketRepository.findOne({ id }, { filters: ['notDeleted'] }); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + if (ticket.user?.id !== userId) { + throw new ForbiddenException('You can only update your own tickets'); + } + this.em.assign(ticket, updateTicketUserDto); + await this.em.flush(); + return ticket; + } + + async remove(id: string) { + const ticket = await this.ticketRepository.findOne({ id }, { filters: ['notDeleted'] }); + if (!ticket) { + throw new NotFoundException('Ticket not found'); + } + ticket.deletedAt = new Date(); + await this.em.flush(); + return ticket; + } +}