notification module
This commit is contained in:
@@ -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: [],
|
||||
|
||||
@@ -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, string> = {
|
||||
[PermissionEnum.MANAGE_LEARNINGS]: "مدیریت آموزشها",
|
||||
[PermissionEnum.MANAGE_CRITICISMS]: "مدیریت انتقادها و پیشنهادها",
|
||||
[PermissionEnum.MANAGE_ANNOUNCEMENTS]: "مدیریت اطلاعیهها",
|
||||
[PermissionEnum.MANAGE_TICKETS]: "مدیریت تیکتها",
|
||||
[PermissionEnum.NEW_REQUEST_NOTIFICATION]: "",
|
||||
[PermissionEnum.NEW_PAYMENT_NOTIFICATION]: ""
|
||||
};
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
import { PartialType } from '@nestjs/swagger';
|
||||
import { CreateTicketDto } from './create-ticket.dto';
|
||||
|
||||
export class UpdateTicketDto extends PartialType(CreateTicketDto) {}
|
||||
@@ -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<Ticket>(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;
|
||||
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum TicketStatusEnum {
|
||||
OPEN = 'open',
|
||||
CLOSED = 'closed',
|
||||
PENDING = 'pending',
|
||||
IN_PROGRESS = 'in_progress',
|
||||
}
|
||||
@@ -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<Ticket> {
|
||||
constructor(readonly em: EntityManager) {
|
||||
super(em, Ticket);
|
||||
}
|
||||
|
||||
async findAllPaginated(dto: SearchTicketQueryDto): Promise<PaginatedResult<Ticket>> {
|
||||
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<Ticket> = {};
|
||||
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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 {}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user