add : TicketApi
This commit is contained in:
@@ -24,6 +24,9 @@ import { PricingModule } from './modules/pricing/pricing.module';
|
||||
import { Settings } from './modules/settings/entities/settings.entity';
|
||||
import { SettingsModule } from './modules/settings/settings.module';
|
||||
import { StorageModule } from './modules/storage/storage.module';
|
||||
import { TicketMessage } from './modules/tickets/entities/ticket-message.entity';
|
||||
import { Ticket } from './modules/tickets/entities/ticket.entity';
|
||||
import { TicketsModule } from './modules/tickets/tickets.module';
|
||||
import { LoyaltyProfile } from './modules/users/entities/loyalty-profile.entity';
|
||||
import { User } from './modules/users/entities/user.entity';
|
||||
import { FavoriteProduct } from './modules/users/entities/favorite-product.entity';
|
||||
@@ -65,12 +68,15 @@ import { UsersModule } from './modules/users/users.module';
|
||||
ProductAttributeValue,
|
||||
Settings,
|
||||
MediaAsset,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
]),
|
||||
RedisCacheModule,
|
||||
StorageModule,
|
||||
UsersModule,
|
||||
SettingsModule,
|
||||
PricingModule,
|
||||
TicketsModule,
|
||||
CatalogModule,
|
||||
MediaModule,
|
||||
AuthModule,
|
||||
|
||||
@@ -12,6 +12,8 @@ import { ProductReview } from '../modules/catalog/entities/product-review.entity
|
||||
import { Settings } from '../modules/settings/entities/settings.entity';
|
||||
import { MediaAsset } from '../modules/media/entities/media-asset.entity';
|
||||
import { LoyaltyProfile } from '../modules/users/entities/loyalty-profile.entity';
|
||||
import { TicketMessage } from '../modules/tickets/entities/ticket-message.entity';
|
||||
import { Ticket } from '../modules/tickets/entities/ticket.entity';
|
||||
import { User } from '../modules/users/entities/user.entity';
|
||||
import { FavoriteProduct } from '../modules/users/entities/favorite-product.entity';
|
||||
import { UserAddress } from '../modules/users/entities/user-address.entity';
|
||||
@@ -52,6 +54,8 @@ export const buildTypeOrmOptions = (
|
||||
ProductAttributeValue,
|
||||
Settings,
|
||||
MediaAsset,
|
||||
Ticket,
|
||||
TicketMessage,
|
||||
],
|
||||
autoLoadEntities: false,
|
||||
synchronize: true,
|
||||
|
||||
@@ -256,6 +256,7 @@ export class AuthService {
|
||||
'brands.manage',
|
||||
'users.manage',
|
||||
'media.manage',
|
||||
'tickets.manage',
|
||||
];
|
||||
}
|
||||
|
||||
|
||||
68
src/modules/tickets/admin-tickets.controller.ts
Normal file
68
src/modules/tickets/admin-tickets.controller.ts
Normal file
@@ -0,0 +1,68 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { Permissions } from '../../common/decorators/permissions.decorator';
|
||||
import { Roles } from '../../common/decorators/roles.decorator';
|
||||
import { PermissionsGuard } from '../../common/guards/permissions.guard';
|
||||
import { RolesGuard } from '../../common/guards/roles.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { CreateTicketMessageDto } from './dto/create-ticket-message.dto';
|
||||
import { ListAdminTicketsDto } from './dto/list-admin-tickets.dto';
|
||||
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||
import { TicketsService } from './tickets.service';
|
||||
|
||||
@ApiTags('Admin Tickets')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard, RolesGuard, PermissionsGuard)
|
||||
@Roles(UserRole.ADMIN)
|
||||
@Permissions('tickets.manage')
|
||||
@Controller('admin/tickets')
|
||||
export class AdminTicketsController {
|
||||
constructor(private readonly ticketsService: TicketsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List support tickets for admin panel' })
|
||||
findAll(@Query() query: ListAdminTicketsDto) {
|
||||
return this.ticketsService.listAdmin(query);
|
||||
}
|
||||
|
||||
@Get(':ticketId')
|
||||
@ApiOperation({ summary: 'Get one support ticket for admin panel' })
|
||||
findOne(@Param('ticketId') ticketId: string) {
|
||||
return this.ticketsService.getAdminOne(ticketId);
|
||||
}
|
||||
|
||||
@Patch(':ticketId')
|
||||
@ApiOperation({ summary: 'Update ticket status, priority, or assignee' })
|
||||
update(@Param('ticketId') ticketId: string, @Body() dto: UpdateTicketDto) {
|
||||
return this.ticketsService.updateAdmin(ticketId, dto);
|
||||
}
|
||||
|
||||
@Post(':ticketId/messages')
|
||||
@ApiOperation({ summary: 'Send an admin reply in one support ticket' })
|
||||
@ApiBody({ type: CreateTicketMessageDto })
|
||||
reply(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateTicketMessageDto,
|
||||
) {
|
||||
return this.ticketsService.replyAsAdmin(request.user.sub, ticketId, dto);
|
||||
}
|
||||
}
|
||||
13
src/modules/tickets/dto/create-ticket-message.dto.ts
Normal file
13
src/modules/tickets/dto/create-ticket-message.dto.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsNotEmpty, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
|
||||
export class CreateTicketMessageDto {
|
||||
@ApiProperty({
|
||||
example: 'لطفا پیگیری کنید. هنوز کد رهگیری دریافت نکردهام.',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(1)
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
33
src/modules/tickets/dto/create-ticket.dto.ts
Normal file
33
src/modules/tickets/dto/create-ticket.dto.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';
|
||||
import { TicketPriority } from '../enums/ticket-priority.enum';
|
||||
|
||||
export class CreateTicketDto {
|
||||
@ApiProperty({
|
||||
example: 'تاخیر در ارسال سفارش',
|
||||
description: 'Short ticket subject shown in ticket list.',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@MaxLength(200)
|
||||
subject: string;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
enum: TicketPriority,
|
||||
default: TicketPriority.NORMAL,
|
||||
})
|
||||
@IsOptional()
|
||||
@IsEnum(TicketPriority)
|
||||
priority?: TicketPriority = TicketPriority.NORMAL;
|
||||
|
||||
@ApiProperty({
|
||||
example: 'سلام، سفارش من هنوز ارسال نشده و میخواهم وضعیت آن را بدانم.',
|
||||
description: 'Initial ticket message body.',
|
||||
})
|
||||
@IsString()
|
||||
@IsNotEmpty()
|
||||
@MinLength(3)
|
||||
@MaxLength(5000)
|
||||
message: string;
|
||||
}
|
||||
50
src/modules/tickets/dto/list-admin-tickets.dto.ts
Normal file
50
src/modules/tickets/dto/list-admin-tickets.dto.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, IsString, IsUUID, MaxLength, Min } from 'class-validator';
|
||||
import { TicketPriority } from '../enums/ticket-priority.enum';
|
||||
import { TicketStatus } from '../enums/ticket-status.enum';
|
||||
|
||||
export class ListAdminTicketsDto {
|
||||
@ApiPropertyOptional({ enum: TicketStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TicketStatus)
|
||||
status?: TicketStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: TicketPriority })
|
||||
@IsOptional()
|
||||
@IsEnum(TicketPriority)
|
||||
priority?: TicketPriority;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Search by ticket number, subject, phone, username, or full name.',
|
||||
example: 'TCK-20260330',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
search?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
userId?: string;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
assignedAdminId?: string;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
}
|
||||
25
src/modules/tickets/dto/list-my-tickets.dto.ts
Normal file
25
src/modules/tickets/dto/list-my-tickets.dto.ts
Normal file
@@ -0,0 +1,25 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsEnum, IsInt, IsOptional, Min } from 'class-validator';
|
||||
import { TicketStatus } from '../enums/ticket-status.enum';
|
||||
|
||||
export class ListMyTicketsDto {
|
||||
@ApiPropertyOptional({ enum: TicketStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TicketStatus)
|
||||
status?: TicketStatus;
|
||||
|
||||
@ApiPropertyOptional({ default: 1 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
page?: number = 1;
|
||||
|
||||
@ApiPropertyOptional({ default: 20 })
|
||||
@IsOptional()
|
||||
@Transform(({ value }) => Number(value))
|
||||
@IsInt()
|
||||
@Min(1)
|
||||
limit?: number = 20;
|
||||
}
|
||||
23
src/modules/tickets/dto/update-ticket.dto.ts
Normal file
23
src/modules/tickets/dto/update-ticket.dto.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsUUID } from 'class-validator';
|
||||
import { TicketPriority } from '../enums/ticket-priority.enum';
|
||||
import { TicketStatus } from '../enums/ticket-status.enum';
|
||||
|
||||
export class UpdateTicketDto {
|
||||
@ApiPropertyOptional({ enum: TicketStatus })
|
||||
@IsOptional()
|
||||
@IsEnum(TicketStatus)
|
||||
status?: TicketStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: TicketPriority })
|
||||
@IsOptional()
|
||||
@IsEnum(TicketPriority)
|
||||
priority?: TicketPriority;
|
||||
|
||||
@ApiPropertyOptional({
|
||||
description: 'Assign ticket to one admin user.',
|
||||
})
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
assignedAdminId?: string;
|
||||
}
|
||||
41
src/modules/tickets/entities/ticket-message.entity.ts
Normal file
41
src/modules/tickets/entities/ticket-message.entity.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { TicketMessageSender } from '../enums/ticket-message-sender.enum';
|
||||
import { Ticket } from './ticket.entity';
|
||||
|
||||
@Entity({ name: 'ticket_messages' })
|
||||
export class TicketMessage {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@ManyToOne(() => Ticket, (ticket) => ticket.messages, {
|
||||
nullable: false,
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
@JoinColumn({ name: 'ticket_id' })
|
||||
ticket: Ticket;
|
||||
|
||||
@ManyToOne(() => User, { nullable: false, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'sender_id' })
|
||||
sender: User;
|
||||
|
||||
@Column({
|
||||
name: 'sender_type',
|
||||
type: 'enum',
|
||||
enum: TicketMessageSender,
|
||||
})
|
||||
senderType: TicketMessageSender;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
message: string;
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
}
|
||||
70
src/modules/tickets/entities/ticket.entity.ts
Normal file
70
src/modules/tickets/entities/ticket.entity.ts
Normal file
@@ -0,0 +1,70 @@
|
||||
import {
|
||||
Column,
|
||||
CreateDateColumn,
|
||||
Entity,
|
||||
Index,
|
||||
JoinColumn,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
UpdateDateColumn,
|
||||
} from 'typeorm';
|
||||
import { User } from '../../users/entities/user.entity';
|
||||
import { TicketPriority } from '../enums/ticket-priority.enum';
|
||||
import { TicketStatus } from '../enums/ticket-status.enum';
|
||||
import { TicketMessage } from './ticket-message.entity';
|
||||
|
||||
@Entity({ name: 'tickets' })
|
||||
export class Ticket {
|
||||
@PrimaryGeneratedColumn('uuid')
|
||||
id: string;
|
||||
|
||||
@Index({ unique: true })
|
||||
@Column({ name: 'ticket_number', type: 'varchar', length: 32 })
|
||||
ticketNumber: string;
|
||||
|
||||
@Column({ type: 'varchar', length: 200 })
|
||||
subject: string;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TicketStatus,
|
||||
default: TicketStatus.WAITING_FOR_ADMIN,
|
||||
})
|
||||
status: TicketStatus;
|
||||
|
||||
@Column({
|
||||
type: 'enum',
|
||||
enum: TicketPriority,
|
||||
default: TicketPriority.NORMAL,
|
||||
})
|
||||
priority: TicketPriority;
|
||||
|
||||
@Column({ type: 'boolean', default: false })
|
||||
isReadByAdmin: boolean;
|
||||
|
||||
@Column({ type: 'boolean', default: true })
|
||||
isReadByUser: boolean;
|
||||
|
||||
@Column({ name: 'last_message_at', type: 'timestamp', nullable: true })
|
||||
lastMessageAt?: Date | null;
|
||||
|
||||
@ManyToOne(() => User, { nullable: false, onDelete: 'CASCADE' })
|
||||
@JoinColumn({ name: 'user_id' })
|
||||
user: User;
|
||||
|
||||
@ManyToOne(() => User, { nullable: true, onDelete: 'SET NULL' })
|
||||
@JoinColumn({ name: 'assigned_admin_id' })
|
||||
assignedAdmin?: User | null;
|
||||
|
||||
@OneToMany(() => TicketMessage, (message) => message.ticket, {
|
||||
cascade: false,
|
||||
})
|
||||
messages: TicketMessage[];
|
||||
|
||||
@CreateDateColumn({ name: 'created_at' })
|
||||
createdAt: Date;
|
||||
|
||||
@UpdateDateColumn({ name: 'updated_at' })
|
||||
updatedAt: Date;
|
||||
}
|
||||
4
src/modules/tickets/enums/ticket-message-sender.enum.ts
Normal file
4
src/modules/tickets/enums/ticket-message-sender.enum.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export enum TicketMessageSender {
|
||||
USER = 'user',
|
||||
ADMIN = 'admin',
|
||||
}
|
||||
6
src/modules/tickets/enums/ticket-priority.enum.ts
Normal file
6
src/modules/tickets/enums/ticket-priority.enum.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
export enum TicketPriority {
|
||||
LOW = 'low',
|
||||
NORMAL = 'normal',
|
||||
HIGH = 'high',
|
||||
URGENT = 'urgent',
|
||||
}
|
||||
5
src/modules/tickets/enums/ticket-status.enum.ts
Normal file
5
src/modules/tickets/enums/ticket-status.enum.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export enum TicketStatus {
|
||||
WAITING_FOR_ADMIN = 'waiting_for_admin',
|
||||
WAITING_FOR_USER = 'waiting_for_user',
|
||||
CLOSED = 'closed',
|
||||
}
|
||||
16
src/modules/tickets/tickets.module.ts
Normal file
16
src/modules/tickets/tickets.module.ts
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { AdminTicketsController } from './admin-tickets.controller';
|
||||
import { TicketMessage } from './entities/ticket-message.entity';
|
||||
import { Ticket } from './entities/ticket.entity';
|
||||
import { TicketsService } from './tickets.service';
|
||||
import { UserTicketsController } from './user-tickets.controller';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Ticket, TicketMessage, User])],
|
||||
controllers: [UserTicketsController, AdminTicketsController],
|
||||
providers: [TicketsService],
|
||||
exports: [TicketsService],
|
||||
})
|
||||
export class TicketsModule {}
|
||||
437
src/modules/tickets/tickets.service.ts
Normal file
437
src/modules/tickets/tickets.service.ts
Normal file
@@ -0,0 +1,437 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Brackets, Repository } from 'typeorm';
|
||||
import { User } from '../users/entities/user.entity';
|
||||
import { UserRole } from '../users/enums/user-role.enum';
|
||||
import { CreateTicketDto } from './dto/create-ticket.dto';
|
||||
import { CreateTicketMessageDto } from './dto/create-ticket-message.dto';
|
||||
import { ListAdminTicketsDto } from './dto/list-admin-tickets.dto';
|
||||
import { ListMyTicketsDto } from './dto/list-my-tickets.dto';
|
||||
import { UpdateTicketDto } from './dto/update-ticket.dto';
|
||||
import { TicketMessage } from './entities/ticket-message.entity';
|
||||
import { Ticket } from './entities/ticket.entity';
|
||||
import { TicketMessageSender } from './enums/ticket-message-sender.enum';
|
||||
import { TicketPriority } from './enums/ticket-priority.enum';
|
||||
import { TicketStatus } from './enums/ticket-status.enum';
|
||||
|
||||
@Injectable()
|
||||
export class TicketsService {
|
||||
constructor(
|
||||
@InjectRepository(Ticket)
|
||||
private readonly ticketsRepository: Repository<Ticket>,
|
||||
@InjectRepository(TicketMessage)
|
||||
private readonly ticketMessagesRepository: Repository<TicketMessage>,
|
||||
@InjectRepository(User)
|
||||
private readonly usersRepository: Repository<User>,
|
||||
) {}
|
||||
|
||||
async createForUser(userId: string, dto: CreateTicketDto) {
|
||||
const user = await this.requireUser(userId);
|
||||
|
||||
const ticket = this.ticketsRepository.create({
|
||||
ticketNumber: await this.generateTicketNumber(),
|
||||
subject: dto.subject.trim(),
|
||||
priority: dto.priority ?? TicketPriority.NORMAL,
|
||||
status: TicketStatus.WAITING_FOR_ADMIN,
|
||||
isReadByAdmin: false,
|
||||
isReadByUser: true,
|
||||
lastMessageAt: new Date(),
|
||||
user,
|
||||
});
|
||||
const savedTicket = await this.ticketsRepository.save(ticket);
|
||||
|
||||
const initialMessage = this.ticketMessagesRepository.create({
|
||||
ticket: savedTicket,
|
||||
sender: user,
|
||||
senderType: TicketMessageSender.USER,
|
||||
message: dto.message.trim(),
|
||||
});
|
||||
await this.ticketMessagesRepository.save(initialMessage);
|
||||
|
||||
return this.getForUser(userId, savedTicket.id);
|
||||
}
|
||||
|
||||
async listForUser(userId: string, query: ListMyTicketsDto) {
|
||||
await this.requireUser(userId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
const qb = this.ticketsRepository
|
||||
.createQueryBuilder('ticket')
|
||||
.leftJoinAndSelect('ticket.messages', 'messages')
|
||||
.where('ticket.user_id = :userId', { userId })
|
||||
.orderBy('ticket.last_message_at', 'DESC', 'NULLS LAST')
|
||||
.addOrderBy('ticket.created_at', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('ticket.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((ticket) => this.mapTicketSummary(ticket, false)),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getForUser(userId: string, ticketId: string) {
|
||||
const ticket = await this.requireOwnedTicket(userId, ticketId);
|
||||
|
||||
if (!ticket.isReadByUser) {
|
||||
ticket.isReadByUser = true;
|
||||
await this.ticketsRepository.save(ticket);
|
||||
}
|
||||
|
||||
return this.mapTicketDetail(ticket, false);
|
||||
}
|
||||
|
||||
async replyAsUser(userId: string, ticketId: string, dto: CreateTicketMessageDto) {
|
||||
const [user, ticket] = await Promise.all([
|
||||
this.requireUser(userId),
|
||||
this.requireOwnedTicket(userId, ticketId),
|
||||
]);
|
||||
|
||||
if (ticket.status === TicketStatus.CLOSED) {
|
||||
throw new BadRequestException('Closed ticket cannot receive new messages');
|
||||
}
|
||||
|
||||
const message = this.ticketMessagesRepository.create({
|
||||
ticket,
|
||||
sender: user,
|
||||
senderType: TicketMessageSender.USER,
|
||||
message: dto.message.trim(),
|
||||
});
|
||||
await this.ticketMessagesRepository.save(message);
|
||||
|
||||
ticket.status = TicketStatus.WAITING_FOR_ADMIN;
|
||||
ticket.isReadByAdmin = false;
|
||||
ticket.isReadByUser = true;
|
||||
ticket.lastMessageAt = new Date();
|
||||
await this.ticketsRepository.save(ticket);
|
||||
|
||||
return this.getForUser(userId, ticketId);
|
||||
}
|
||||
|
||||
async closeAsUser(userId: string, ticketId: string) {
|
||||
const ticket = await this.requireOwnedTicket(userId, ticketId);
|
||||
ticket.status = TicketStatus.CLOSED;
|
||||
ticket.isReadByUser = true;
|
||||
await this.ticketsRepository.save(ticket);
|
||||
|
||||
return this.mapTicketDetail(ticket, false);
|
||||
}
|
||||
|
||||
async listAdmin(query: ListAdminTicketsDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
|
||||
const qb = this.ticketsRepository
|
||||
.createQueryBuilder('ticket')
|
||||
.leftJoinAndSelect('ticket.user', 'user')
|
||||
.leftJoinAndSelect('ticket.assignedAdmin', 'assignedAdmin')
|
||||
.leftJoinAndSelect('ticket.messages', 'messages')
|
||||
.leftJoinAndSelect('messages.sender', 'sender')
|
||||
.orderBy('ticket.last_message_at', 'DESC', 'NULLS LAST')
|
||||
.addOrderBy('ticket.created_at', 'DESC')
|
||||
.skip((page - 1) * limit)
|
||||
.take(limit);
|
||||
|
||||
if (query.status) {
|
||||
qb.andWhere('ticket.status = :status', { status: query.status });
|
||||
}
|
||||
|
||||
if (query.priority) {
|
||||
qb.andWhere('ticket.priority = :priority', { priority: query.priority });
|
||||
}
|
||||
|
||||
if (query.userId) {
|
||||
qb.andWhere('ticket.user_id = :userId', { userId: query.userId });
|
||||
}
|
||||
|
||||
if (query.assignedAdminId) {
|
||||
qb.andWhere('ticket.assigned_admin_id = :assignedAdminId', {
|
||||
assignedAdminId: query.assignedAdminId,
|
||||
});
|
||||
}
|
||||
|
||||
if (query.search?.trim()) {
|
||||
const search = `%${query.search.trim()}%`;
|
||||
qb.andWhere(
|
||||
new Brackets((subQb) => {
|
||||
subQb
|
||||
.where('ticket.ticket_number ILIKE :search', { search })
|
||||
.orWhere('ticket.subject ILIKE :search', { search })
|
||||
.orWhere('user.full_name ILIKE :search', { search })
|
||||
.orWhere('user.username ILIKE :search', { search })
|
||||
.orWhere('user.phone ILIKE :search', { search });
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
const [items, total] = await qb.getManyAndCount();
|
||||
|
||||
return {
|
||||
items: items.map((ticket) => this.mapTicketSummary(ticket, true)),
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages: Math.ceil(total / limit) || 1,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async getAdminOne(ticketId: string) {
|
||||
const ticket = await this.requireTicket(ticketId);
|
||||
|
||||
if (!ticket.isReadByAdmin) {
|
||||
ticket.isReadByAdmin = true;
|
||||
await this.ticketsRepository.save(ticket);
|
||||
}
|
||||
|
||||
return this.mapTicketDetail(ticket, true);
|
||||
}
|
||||
|
||||
async updateAdmin(ticketId: string, dto: UpdateTicketDto) {
|
||||
const ticket = await this.requireTicket(ticketId);
|
||||
|
||||
if (dto.priority) {
|
||||
ticket.priority = dto.priority;
|
||||
}
|
||||
|
||||
if (dto.status) {
|
||||
ticket.status = dto.status;
|
||||
}
|
||||
|
||||
if (dto.assignedAdminId) {
|
||||
ticket.assignedAdmin = await this.requireAdmin(dto.assignedAdminId);
|
||||
}
|
||||
|
||||
const savedTicket = await this.ticketsRepository.save(ticket);
|
||||
return this.mapTicketDetail(savedTicket, true);
|
||||
}
|
||||
|
||||
async replyAsAdmin(
|
||||
adminUserId: string,
|
||||
ticketId: string,
|
||||
dto: CreateTicketMessageDto,
|
||||
) {
|
||||
const [adminUser, ticket] = await Promise.all([
|
||||
this.requireAdmin(adminUserId),
|
||||
this.requireTicket(ticketId),
|
||||
]);
|
||||
|
||||
if (ticket.status === TicketStatus.CLOSED) {
|
||||
throw new BadRequestException('Closed ticket cannot receive new messages');
|
||||
}
|
||||
|
||||
const message = this.ticketMessagesRepository.create({
|
||||
ticket,
|
||||
sender: adminUser,
|
||||
senderType: TicketMessageSender.ADMIN,
|
||||
message: dto.message.trim(),
|
||||
});
|
||||
await this.ticketMessagesRepository.save(message);
|
||||
|
||||
ticket.status = TicketStatus.WAITING_FOR_USER;
|
||||
ticket.assignedAdmin = adminUser;
|
||||
ticket.isReadByAdmin = true;
|
||||
ticket.isReadByUser = false;
|
||||
ticket.lastMessageAt = new Date();
|
||||
await this.ticketsRepository.save(ticket);
|
||||
|
||||
return this.getAdminOne(ticketId);
|
||||
}
|
||||
|
||||
async countUserTickets(userId: string) {
|
||||
return this.ticketsRepository.count({
|
||||
where: { user: { id: userId } },
|
||||
});
|
||||
}
|
||||
|
||||
private async requireUser(userId: string) {
|
||||
const user = await this.usersRepository.findOne({
|
||||
where: { id: userId },
|
||||
});
|
||||
|
||||
if (!user) {
|
||||
throw new NotFoundException('User not found');
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
|
||||
private async requireAdmin(adminUserId: string) {
|
||||
const adminUser = await this.usersRepository.findOne({
|
||||
where: {
|
||||
id: adminUserId,
|
||||
role: UserRole.ADMIN,
|
||||
},
|
||||
});
|
||||
|
||||
if (!adminUser) {
|
||||
throw new NotFoundException('Admin user not found');
|
||||
}
|
||||
|
||||
return adminUser;
|
||||
}
|
||||
|
||||
private async requireOwnedTicket(userId: string, ticketId: string) {
|
||||
const ticket = await this.ticketsRepository.findOne({
|
||||
where: {
|
||||
id: ticketId,
|
||||
user: { id: userId },
|
||||
},
|
||||
relations: {
|
||||
user: true,
|
||||
assignedAdmin: true,
|
||||
messages: { sender: true },
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Ticket not found');
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private async requireTicket(ticketId: string) {
|
||||
const ticket = await this.ticketsRepository.findOne({
|
||||
where: { id: ticketId },
|
||||
relations: {
|
||||
user: true,
|
||||
assignedAdmin: true,
|
||||
messages: { sender: true },
|
||||
},
|
||||
});
|
||||
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Ticket not found');
|
||||
}
|
||||
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private mapTicketSummary(ticket: Ticket, includeUser: boolean) {
|
||||
const sortedMessages = [...(ticket.messages ?? [])].sort(
|
||||
(a, b) => a.createdAt.getTime() - b.createdAt.getTime(),
|
||||
);
|
||||
const lastMessage = sortedMessages.at(-1) ?? null;
|
||||
|
||||
return {
|
||||
id: ticket.id,
|
||||
ticketNumber: ticket.ticketNumber,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
isReadByAdmin: ticket.isReadByAdmin,
|
||||
isReadByUser: ticket.isReadByUser,
|
||||
messagesCount: sortedMessages.length,
|
||||
lastMessageAt: ticket.lastMessageAt,
|
||||
lastMessage: lastMessage
|
||||
? {
|
||||
id: lastMessage.id,
|
||||
senderType: lastMessage.senderType,
|
||||
message: lastMessage.message,
|
||||
createdAt: lastMessage.createdAt,
|
||||
}
|
||||
: null,
|
||||
assignedAdmin: ticket.assignedAdmin
|
||||
? {
|
||||
id: ticket.assignedAdmin.id,
|
||||
fullName: ticket.assignedAdmin.fullName,
|
||||
username: ticket.assignedAdmin.username,
|
||||
}
|
||||
: null,
|
||||
...(includeUser
|
||||
? {
|
||||
user: {
|
||||
id: ticket.user.id,
|
||||
fullName: ticket.user.fullName,
|
||||
phone: ticket.user.phone,
|
||||
username: ticket.user.username,
|
||||
},
|
||||
}
|
||||
: {}),
|
||||
createdAt: ticket.createdAt,
|
||||
updatedAt: ticket.updatedAt,
|
||||
};
|
||||
}
|
||||
|
||||
private mapTicketDetail(ticket: Ticket, includeUser: boolean) {
|
||||
const messages = [...(ticket.messages ?? [])]
|
||||
.sort((a, b) => a.createdAt.getTime() - b.createdAt.getTime())
|
||||
.map((message) => ({
|
||||
id: message.id,
|
||||
senderType: message.senderType,
|
||||
sender: {
|
||||
id: message.sender.id,
|
||||
fullName: message.sender.fullName,
|
||||
username: message.sender.username,
|
||||
phone:
|
||||
message.senderType === TicketMessageSender.ADMIN
|
||||
? null
|
||||
: message.sender.phone,
|
||||
},
|
||||
message: message.message,
|
||||
createdAt: message.createdAt,
|
||||
}));
|
||||
|
||||
return {
|
||||
id: ticket.id,
|
||||
ticketNumber: ticket.ticketNumber,
|
||||
subject: ticket.subject,
|
||||
status: ticket.status,
|
||||
priority: ticket.priority,
|
||||
isReadByAdmin: ticket.isReadByAdmin,
|
||||
isReadByUser: ticket.isReadByUser,
|
||||
user: {
|
||||
id: ticket.user.id,
|
||||
fullName: ticket.user.fullName,
|
||||
phone: ticket.user.phone,
|
||||
username: ticket.user.username,
|
||||
},
|
||||
assignedAdmin: ticket.assignedAdmin
|
||||
? {
|
||||
id: ticket.assignedAdmin.id,
|
||||
fullName: ticket.assignedAdmin.fullName,
|
||||
username: ticket.assignedAdmin.username,
|
||||
}
|
||||
: null,
|
||||
messages,
|
||||
lastMessageAt: ticket.lastMessageAt,
|
||||
createdAt: ticket.createdAt,
|
||||
updatedAt: ticket.updatedAt,
|
||||
...(includeUser ? {} : {}),
|
||||
};
|
||||
}
|
||||
|
||||
private async generateTicketNumber() {
|
||||
for (let attempt = 0; attempt < 5; attempt += 1) {
|
||||
const ticketNumber = `TCK-${new Date().getFullYear()}-${Date.now().toString().slice(-8)}-${Math.floor(100 + Math.random() * 900)}`;
|
||||
const exists = await this.ticketsRepository.findOne({
|
||||
where: { ticketNumber },
|
||||
select: { id: true, ticketNumber: true } as never,
|
||||
});
|
||||
|
||||
if (!exists) {
|
||||
return ticketNumber;
|
||||
}
|
||||
}
|
||||
|
||||
throw new BadRequestException('Could not generate unique ticket number');
|
||||
}
|
||||
}
|
||||
80
src/modules/tickets/user-tickets.controller.ts
Normal file
80
src/modules/tickets/user-tickets.controller.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
ApiBearerAuth,
|
||||
ApiBody,
|
||||
ApiOperation,
|
||||
ApiTags,
|
||||
} from '@nestjs/swagger';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { JwtPayload } from '../auth/interfaces/jwt-payload.interface';
|
||||
import { CreateTicketMessageDto } from './dto/create-ticket-message.dto';
|
||||
import { CreateTicketDto } from './dto/create-ticket.dto';
|
||||
import { ListMyTicketsDto } from './dto/list-my-tickets.dto';
|
||||
import { TicketsService } from './tickets.service';
|
||||
|
||||
@ApiTags('User Tickets')
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Controller('users/me/tickets')
|
||||
export class UserTicketsController {
|
||||
constructor(private readonly ticketsService: TicketsService) {}
|
||||
|
||||
@Get()
|
||||
@ApiOperation({ summary: 'List tickets for current authenticated user' })
|
||||
findMyTickets(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Query() query: ListMyTicketsDto,
|
||||
) {
|
||||
return this.ticketsService.listForUser(request.user.sub, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a new support ticket for current user' })
|
||||
@ApiBody({ type: CreateTicketDto })
|
||||
create(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Body() dto: CreateTicketDto,
|
||||
) {
|
||||
return this.ticketsService.createForUser(request.user.sub, dto);
|
||||
}
|
||||
|
||||
@Get(':ticketId')
|
||||
@ApiOperation({ summary: 'Get one support ticket for current user' })
|
||||
findOne(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Param('ticketId') ticketId: string,
|
||||
) {
|
||||
return this.ticketsService.getForUser(request.user.sub, ticketId);
|
||||
}
|
||||
|
||||
@Post(':ticketId/messages')
|
||||
@ApiOperation({ summary: 'Send a new message in one user ticket' })
|
||||
@ApiBody({ type: CreateTicketMessageDto })
|
||||
reply(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateTicketMessageDto,
|
||||
) {
|
||||
return this.ticketsService.replyAsUser(request.user.sub, ticketId, dto);
|
||||
}
|
||||
|
||||
@Patch(':ticketId/close')
|
||||
@ApiOperation({ summary: 'Close one support ticket by current user' })
|
||||
close(
|
||||
@Req() request: Request & { user: JwtPayload },
|
||||
@Param('ticketId') ticketId: string,
|
||||
) {
|
||||
return this.ticketsService.closeAsUser(request.user.sub, ticketId);
|
||||
}
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { UserSession } from '../auth/entities/user-session.entity';
|
||||
import { Product } from '../catalog/entities/product.entity';
|
||||
import { Ticket } from '../tickets/entities/ticket.entity';
|
||||
import { FavoriteProduct } from './entities/favorite-product.entity';
|
||||
import { LoyaltyProfile } from './entities/loyalty-profile.entity';
|
||||
import { User } from './entities/user.entity';
|
||||
@@ -28,6 +29,7 @@ import { UsersService } from './users.service';
|
||||
UserOrder,
|
||||
UserOrderItem,
|
||||
Product,
|
||||
Ticket,
|
||||
]),
|
||||
],
|
||||
controllers: [UsersController],
|
||||
|
||||
@@ -7,6 +7,7 @@ import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { IsNull, Repository } from 'typeorm';
|
||||
import { UserSession } from '../auth/entities/user-session.entity';
|
||||
import { Product } from '../catalog/entities/product.entity';
|
||||
import { Ticket } from '../tickets/entities/ticket.entity';
|
||||
import { CreateUserAddressDto } from './dto/create-user-address.dto';
|
||||
import { ListUserOrdersDto } from './dto/list-user-orders.dto';
|
||||
import { ListWalletTransactionsDto } from './dto/list-wallet-transactions.dto';
|
||||
@@ -49,6 +50,8 @@ export class UsersService {
|
||||
private readonly userOrdersRepository: Repository<UserOrder>,
|
||||
@InjectRepository(UserOrderItem)
|
||||
private readonly userOrderItemsRepository: Repository<UserOrderItem>,
|
||||
@InjectRepository(Ticket)
|
||||
private readonly ticketsRepository: Repository<Ticket>,
|
||||
) {}
|
||||
|
||||
findByPhone(phone: string) {
|
||||
@@ -157,6 +160,7 @@ export class UsersService {
|
||||
favoritesCount,
|
||||
addressesCount,
|
||||
ordersCount,
|
||||
ticketsCount,
|
||||
] =
|
||||
await Promise.all([
|
||||
this.userSessionsRepository.count({
|
||||
@@ -190,6 +194,11 @@ export class UsersService {
|
||||
user: { id: user.id },
|
||||
},
|
||||
}),
|
||||
this.ticketsRepository.count({
|
||||
where: {
|
||||
user: { id: user.id },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
return {
|
||||
@@ -201,7 +210,7 @@ export class UsersService {
|
||||
addressesCount,
|
||||
ordersCount,
|
||||
favoritesCount,
|
||||
ticketsCount: 0,
|
||||
ticketsCount,
|
||||
},
|
||||
loyalty: {
|
||||
currentLevel: user.loyaltyProfile.currentLevel,
|
||||
|
||||
Reference in New Issue
Block a user