add : TicketApi

This commit is contained in:
2026-03-30 13:41:30 +03:00
parent 4a5c0750ca
commit ea179286a4
19 changed files with 894 additions and 1 deletions

View File

@@ -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,

View File

@@ -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,

View File

@@ -256,6 +256,7 @@ export class AuthService {
'brands.manage',
'users.manage',
'media.manage',
'tickets.manage',
];
}

View 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);
}
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View 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;
}

View File

@@ -0,0 +1,4 @@
export enum TicketMessageSender {
USER = 'user',
ADMIN = 'admin',
}

View File

@@ -0,0 +1,6 @@
export enum TicketPriority {
LOW = 'low',
NORMAL = 'normal',
HIGH = 'high',
URGENT = 'urgent',
}

View File

@@ -0,0 +1,5 @@
export enum TicketStatus {
WAITING_FOR_ADMIN = 'waiting_for_admin',
WAITING_FOR_USER = 'waiting_for_user',
CLOSED = 'closed',
}

View 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 {}

View 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');
}
}

View 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);
}
}

View File

@@ -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],

View File

@@ -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,