notification module

This commit is contained in:
2026-02-23 11:44:57 +03:30
parent cc9f52652d
commit 937b985229
15 changed files with 772 additions and 149 deletions
+2
View File
@@ -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: [],
+2
View File
@@ -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;
}
+6
View File
@@ -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,
},
};
}
}
+107
View File
@@ -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);
}
}
+16
View File
@@ -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 {}
+177
View File
@@ -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;
}
}