feat: add support tickets backend and dashboard
This commit is contained in:
@@ -30,6 +30,7 @@ import { ReportsModule } from './modules/reports/reports.module';
|
||||
import { SavesModule } from './modules/saves/saves.module';
|
||||
import { SearchModule } from './modules/search/search.module';
|
||||
import { SuperAdminModule } from './modules/superadmin/superadmin.module';
|
||||
import { SupportModule } from './modules/support/support.module';
|
||||
import { UsersModule } from './modules/users/users.module';
|
||||
import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
|
||||
@@ -66,6 +67,7 @@ import { ThrottleGuard } from './common/guards/throttle.guard';
|
||||
ReportsModule,
|
||||
SavesModule,
|
||||
SearchModule,
|
||||
SupportModule,
|
||||
SuperAdminModule,
|
||||
],
|
||||
controllers: [AppController],
|
||||
|
||||
@@ -305,6 +305,10 @@ export class NotificationsService {
|
||||
return 'Follow request approved';
|
||||
case 'follow_request_rejected':
|
||||
return 'Follow request rejected';
|
||||
case 'support_reply':
|
||||
return 'Support reply';
|
||||
case 'support_ticket_status':
|
||||
return 'Support ticket updated';
|
||||
default:
|
||||
return 'Notification';
|
||||
}
|
||||
@@ -323,6 +327,9 @@ export class NotificationsService {
|
||||
return 'system';
|
||||
case 'collaboration_request':
|
||||
return 'post';
|
||||
case 'support_reply':
|
||||
case 'support_ticket_status':
|
||||
return 'support_ticket';
|
||||
default:
|
||||
return 'post';
|
||||
}
|
||||
@@ -345,6 +352,10 @@ export class NotificationsService {
|
||||
return '';
|
||||
}
|
||||
|
||||
if (resourceType === 'support_ticket' && referenceId) {
|
||||
return `/support/tickets/${referenceId}`;
|
||||
}
|
||||
|
||||
if (referenceId) {
|
||||
return `/posts/${referenceId}`;
|
||||
}
|
||||
|
||||
@@ -18,6 +18,8 @@ export const NOTIFICATION_TYPES = [
|
||||
'follow_request',
|
||||
'follow_request_approved',
|
||||
'follow_request_rejected',
|
||||
'support_reply',
|
||||
'support_ticket_status',
|
||||
] as const;
|
||||
export type NotificationType = (typeof NOTIFICATION_TYPES)[number];
|
||||
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsMongoId, IsOptional } from 'class-validator';
|
||||
|
||||
export class AssignSupportTicketDto {
|
||||
@ApiPropertyOptional({ description: 'Admin user id. Omit or null to unassign.' })
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
adminId?: string | null;
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsOptional, IsString, Length } from 'class-validator';
|
||||
|
||||
export class CreateSupportMessageDto {
|
||||
@ApiPropertyOptional({ maxLength: 5000 })
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@Length(0, 5000)
|
||||
message?: string;
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsOptional, IsString, Length } from 'class-validator';
|
||||
import {
|
||||
SUPPORT_TICKET_CATEGORIES,
|
||||
SUPPORT_TICKET_PRIORITIES,
|
||||
SupportTicketCategory,
|
||||
SupportTicketPriority,
|
||||
} from '../schemas/support-ticket.schema';
|
||||
|
||||
export class CreateSupportTicketDto {
|
||||
@ApiProperty({ maxLength: 150 })
|
||||
@IsString()
|
||||
@Length(1, 150)
|
||||
subject!: string;
|
||||
|
||||
@ApiProperty({ maxLength: 5000 })
|
||||
@IsString()
|
||||
@Length(1, 5000)
|
||||
message!: string;
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_CATEGORIES, default: 'technical' })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_CATEGORIES)
|
||||
category?: SupportTicketCategory = 'technical';
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_PRIORITIES, default: 'normal' })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_PRIORITIES)
|
||||
priority?: SupportTicketPriority = 'normal';
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { ApiPropertyOptional } from '@nestjs/swagger';
|
||||
import { IsEnum, IsMongoId, IsOptional } from 'class-validator';
|
||||
import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto';
|
||||
import {
|
||||
SUPPORT_TICKET_CATEGORIES,
|
||||
SUPPORT_TICKET_PRIORITIES,
|
||||
SUPPORT_TICKET_STATUSES,
|
||||
SupportTicketCategory,
|
||||
SupportTicketPriority,
|
||||
SupportTicketStatus,
|
||||
} from '../schemas/support-ticket.schema';
|
||||
|
||||
export class SupportTicketQueryDto extends PaginationQueryDto {
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_STATUSES })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_STATUSES)
|
||||
status?: SupportTicketStatus;
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_CATEGORIES })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_CATEGORIES)
|
||||
category?: SupportTicketCategory;
|
||||
|
||||
@ApiPropertyOptional({ enum: SUPPORT_TICKET_PRIORITIES })
|
||||
@IsOptional()
|
||||
@IsEnum(SUPPORT_TICKET_PRIORITIES)
|
||||
priority?: SupportTicketPriority;
|
||||
|
||||
@ApiPropertyOptional()
|
||||
@IsOptional()
|
||||
@IsMongoId()
|
||||
userId?: string;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { ApiProperty } from '@nestjs/swagger';
|
||||
import { IsEnum } from 'class-validator';
|
||||
import { SUPPORT_TICKET_STATUSES, SupportTicketStatus } from '../schemas/support-ticket.schema';
|
||||
|
||||
export class UpdateSupportTicketStatusDto {
|
||||
@ApiProperty({ enum: SUPPORT_TICKET_STATUSES })
|
||||
@IsEnum(SUPPORT_TICKET_STATUSES)
|
||||
status!: SupportTicketStatus;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
import { SupportTicket } from './support-ticket.schema';
|
||||
|
||||
export type SupportTicketMessageDocument = HydratedDocument<SupportTicketMessage>;
|
||||
|
||||
export const SUPPORT_MESSAGE_SENDER_ROLES = ['user', 'admin', 'superadmin', 'system'] as const;
|
||||
export type SupportMessageSenderRole = (typeof SUPPORT_MESSAGE_SENDER_ROLES)[number];
|
||||
|
||||
export const SUPPORT_MESSAGE_ATTACHMENT_TYPES = ['image', 'file'] as const;
|
||||
export type SupportMessageAttachmentType = (typeof SUPPORT_MESSAGE_ATTACHMENT_TYPES)[number];
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class SupportTicketMessage {
|
||||
@Prop({ type: Types.ObjectId, ref: SupportTicket.name, required: true, index: true })
|
||||
ticketId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, default: null, index: true })
|
||||
senderId!: Types.ObjectId | null;
|
||||
|
||||
@Prop({ type: String, default: '', trim: true, index: true })
|
||||
senderIdentifier!: string;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_MESSAGE_SENDER_ROLES, required: true })
|
||||
senderRole!: SupportMessageSenderRole;
|
||||
|
||||
@Prop({ type: String, default: '', trim: true, maxlength: 5000 })
|
||||
message!: string;
|
||||
|
||||
@Prop({ type: String, default: '', trim: true })
|
||||
attachmentUrl!: string;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_MESSAGE_ATTACHMENT_TYPES, default: null })
|
||||
attachmentType!: SupportMessageAttachmentType | null;
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
readByUser!: boolean;
|
||||
|
||||
@Prop({ type: Boolean, default: false })
|
||||
readByAdmin!: boolean;
|
||||
}
|
||||
|
||||
export const SupportTicketMessageSchema = SchemaFactory.createForClass(SupportTicketMessage);
|
||||
SupportTicketMessageSchema.index({ ticketId: 1, createdAt: 1 });
|
||||
SupportTicketMessageSchema.index({ senderId: 1, createdAt: -1 });
|
||||
@@ -0,0 +1,65 @@
|
||||
import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose';
|
||||
import { HydratedDocument, Types } from 'mongoose';
|
||||
import { User } from '../../users/schemas/user.schema';
|
||||
|
||||
export type SupportTicketDocument = HydratedDocument<SupportTicket>;
|
||||
|
||||
export const SUPPORT_TICKET_CATEGORIES = [
|
||||
'account',
|
||||
'technical',
|
||||
'media',
|
||||
'payment',
|
||||
'collaboration',
|
||||
'other',
|
||||
] as const;
|
||||
export type SupportTicketCategory = (typeof SUPPORT_TICKET_CATEGORIES)[number];
|
||||
|
||||
export const SUPPORT_TICKET_PRIORITIES = ['low', 'normal', 'high', 'urgent'] as const;
|
||||
export type SupportTicketPriority = (typeof SUPPORT_TICKET_PRIORITIES)[number];
|
||||
|
||||
export const SUPPORT_TICKET_STATUSES = [
|
||||
'open',
|
||||
'pending',
|
||||
'in_progress',
|
||||
'resolved',
|
||||
'closed',
|
||||
] as const;
|
||||
export type SupportTicketStatus = (typeof SUPPORT_TICKET_STATUSES)[number];
|
||||
|
||||
@Schema({ timestamps: true, versionKey: false })
|
||||
export class SupportTicket {
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, required: true, index: true })
|
||||
userId!: Types.ObjectId;
|
||||
|
||||
@Prop({ type: Types.ObjectId, ref: User.name, default: null, index: true })
|
||||
assignedAdminId!: Types.ObjectId | null;
|
||||
|
||||
@Prop({ type: String, required: true, trim: true, maxlength: 150 })
|
||||
subject!: string;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_TICKET_CATEGORIES, default: 'technical', index: true })
|
||||
category!: SupportTicketCategory;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_TICKET_PRIORITIES, default: 'normal', index: true })
|
||||
priority!: SupportTicketPriority;
|
||||
|
||||
@Prop({ type: String, enum: SUPPORT_TICKET_STATUSES, default: 'open', index: true })
|
||||
status!: SupportTicketStatus;
|
||||
|
||||
@Prop({ type: Date, default: Date.now, index: true })
|
||||
lastMessageAt!: Date;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
resolvedAt!: Date | null;
|
||||
|
||||
@Prop({ type: Date, default: null })
|
||||
closedAt!: Date | null;
|
||||
}
|
||||
|
||||
export const SupportTicketSchema = SchemaFactory.createForClass(SupportTicket);
|
||||
SupportTicketSchema.index({ userId: 1, createdAt: -1 });
|
||||
SupportTicketSchema.index({ status: 1, updatedAt: -1 });
|
||||
SupportTicketSchema.index({ category: 1, createdAt: -1 });
|
||||
SupportTicketSchema.index({ priority: 1, createdAt: -1 });
|
||||
SupportTicketSchema.index({ assignedAdminId: 1, status: 1 });
|
||||
SupportTicketSchema.index({ lastMessageAt: -1 });
|
||||
@@ -0,0 +1,159 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Query,
|
||||
UploadedFile,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
|
||||
import { CurrentUser } from '../../common/decorators/current-user.decorator';
|
||||
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
|
||||
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
|
||||
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
|
||||
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
|
||||
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
|
||||
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
|
||||
import { AssignSupportTicketDto } from './dto/assign-support-ticket.dto';
|
||||
import { CreateSupportMessageDto } from './dto/create-support-message.dto';
|
||||
import { CreateSupportTicketDto } from './dto/create-support-ticket.dto';
|
||||
import { SupportTicketQueryDto } from './dto/support-ticket-query.dto';
|
||||
import { UpdateSupportTicketStatusDto } from './dto/update-support-ticket-status.dto';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
type UploadFile = { mimetype?: string; size: number; buffer: Buffer; originalname?: string };
|
||||
|
||||
const supportImageInterceptor = FileInterceptor('image', {
|
||||
limits: { fileSize: 5 * 1024 * 1024, files: 1 },
|
||||
});
|
||||
|
||||
@ApiTags('Support')
|
||||
@Controller('support')
|
||||
export class SupportController {
|
||||
constructor(private readonly supportService: SupportService) {}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('tickets')
|
||||
@UseInterceptors(supportImageInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
required: ['subject', 'message'],
|
||||
properties: {
|
||||
subject: { type: 'string', maxLength: 150 },
|
||||
message: { type: 'string', maxLength: 5000 },
|
||||
category: { type: 'string', enum: ['account', 'technical', 'media', 'payment', 'collaboration', 'other'] },
|
||||
priority: { type: 'string', enum: ['low', 'normal', 'high', 'urgent'] },
|
||||
image: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async createTicket(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Body() dto: CreateSupportTicketDto,
|
||||
@UploadedFile() image?: UploadFile,
|
||||
) {
|
||||
return this.supportService.createTicket(user.sub, dto, image);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('tickets')
|
||||
async myTickets(@CurrentUser() user: JwtPayload, @Query() query: SupportTicketQueryDto) {
|
||||
return this.supportService.listMyTickets(user.sub, query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Get('tickets/:ticketId')
|
||||
async myTicket(@CurrentUser() user: JwtPayload, @Param('ticketId') ticketId: string) {
|
||||
return this.supportService.getMyTicket(user.sub, ticketId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Post('tickets/:ticketId/messages')
|
||||
@UseInterceptors(supportImageInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@ApiBody({
|
||||
schema: {
|
||||
type: 'object',
|
||||
properties: {
|
||||
message: { type: 'string', maxLength: 5000 },
|
||||
image: { type: 'string', format: 'binary' },
|
||||
},
|
||||
},
|
||||
})
|
||||
async addUserMessage(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateSupportMessageDto,
|
||||
@UploadedFile() image?: UploadFile,
|
||||
) {
|
||||
return this.supportService.addUserMessage(user.sub, ticketId, dto, image);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(JwtAuthGuard)
|
||||
@Patch('tickets/:ticketId/close')
|
||||
async closeMyTicket(@CurrentUser() user: JwtPayload, @Param('ticketId') ticketId: string) {
|
||||
return this.supportService.closeMyTicket(user.sub, ticketId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Get('admin/tickets')
|
||||
async adminTickets(@Query() query: SupportTicketQueryDto) {
|
||||
return this.supportService.listAdminTickets(query);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Get('admin/tickets/:ticketId')
|
||||
async adminTicket(@Param('ticketId') ticketId: string) {
|
||||
return this.supportService.getAdminTicket(ticketId);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Post('admin/tickets/:ticketId/messages')
|
||||
@UseInterceptors(supportImageInterceptor)
|
||||
@ApiConsumes('multipart/form-data')
|
||||
async addAdminMessage(
|
||||
@CurrentUser() user: JwtPayload,
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: CreateSupportMessageDto,
|
||||
@UploadedFile() image?: UploadFile,
|
||||
) {
|
||||
return this.supportService.addAdminMessage(user.sub, ticketId, dto, image);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Patch('admin/tickets/:ticketId/status')
|
||||
async updateAdminStatus(
|
||||
@Param('ticketId') ticketId: string,
|
||||
@Body() dto: UpdateSupportTicketStatusDto,
|
||||
) {
|
||||
return this.supportService.updateAdminStatus(ticketId, dto);
|
||||
}
|
||||
|
||||
@ApiBearerAuth()
|
||||
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
|
||||
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CASES_MANAGE)
|
||||
@Patch('admin/tickets/:ticketId/assign')
|
||||
async assignAdmin(@Param('ticketId') ticketId: string, @Body() dto: AssignSupportTicketDto) {
|
||||
return this.supportService.assignAdmin(ticketId, dto);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,25 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { MongooseModule } from '@nestjs/mongoose';
|
||||
import { NotificationsModule } from '../notifications/notifications.module';
|
||||
import { SupportTicket, SupportTicketSchema } from './schemas/support-ticket.schema';
|
||||
import {
|
||||
SupportTicketMessage,
|
||||
SupportTicketMessageSchema,
|
||||
} from './schemas/support-ticket-message.schema';
|
||||
import { SupportController } from './support.controller';
|
||||
import { SupportRepository } from './support.repository';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
NotificationsModule,
|
||||
MongooseModule.forFeature([
|
||||
{ name: SupportTicket.name, schema: SupportTicketSchema },
|
||||
{ name: SupportTicketMessage.name, schema: SupportTicketMessageSchema },
|
||||
]),
|
||||
],
|
||||
controllers: [SupportController],
|
||||
providers: [SupportService, SupportRepository],
|
||||
exports: [SupportService, SupportRepository],
|
||||
})
|
||||
export class SupportModule {}
|
||||
@@ -0,0 +1,156 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
import { InjectModel } from '@nestjs/mongoose';
|
||||
import { FilterQuery, Model, Types } from 'mongoose';
|
||||
import { SupportTicket, SupportTicketDocument, SupportTicketStatus } from './schemas/support-ticket.schema';
|
||||
import {
|
||||
SupportMessageSenderRole,
|
||||
SupportTicketMessage,
|
||||
SupportTicketMessageDocument,
|
||||
} from './schemas/support-ticket-message.schema';
|
||||
|
||||
@Injectable()
|
||||
export class SupportRepository {
|
||||
constructor(
|
||||
@InjectModel(SupportTicket.name)
|
||||
private readonly ticketModel: Model<SupportTicketDocument>,
|
||||
@InjectModel(SupportTicketMessage.name)
|
||||
private readonly messageModel: Model<SupportTicketMessageDocument>,
|
||||
) {}
|
||||
|
||||
async createTicket(payload: {
|
||||
userId: string;
|
||||
subject: string;
|
||||
category: string;
|
||||
priority: string;
|
||||
lastMessageAt: Date;
|
||||
}) {
|
||||
return this.ticketModel.create({
|
||||
userId: new Types.ObjectId(payload.userId),
|
||||
subject: payload.subject,
|
||||
category: payload.category,
|
||||
priority: payload.priority,
|
||||
status: 'open',
|
||||
lastMessageAt: payload.lastMessageAt,
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
});
|
||||
}
|
||||
|
||||
async createMessage(payload: {
|
||||
ticketId: string;
|
||||
senderId?: string | null;
|
||||
senderIdentifier: string;
|
||||
senderRole: SupportMessageSenderRole;
|
||||
message: string;
|
||||
attachmentUrl?: string;
|
||||
attachmentType?: 'image' | 'file' | null;
|
||||
readByUser: boolean;
|
||||
readByAdmin: boolean;
|
||||
}) {
|
||||
return this.messageModel.create({
|
||||
ticketId: new Types.ObjectId(payload.ticketId),
|
||||
senderId: payload.senderId && Types.ObjectId.isValid(payload.senderId) ? new Types.ObjectId(payload.senderId) : null,
|
||||
senderIdentifier: payload.senderIdentifier,
|
||||
senderRole: payload.senderRole,
|
||||
message: payload.message,
|
||||
attachmentUrl: payload.attachmentUrl ?? '',
|
||||
attachmentType: payload.attachmentType ?? null,
|
||||
readByUser: payload.readByUser,
|
||||
readByAdmin: payload.readByAdmin,
|
||||
});
|
||||
}
|
||||
|
||||
async findTicketById(ticketId: string) {
|
||||
return this.ticketModel.findById(ticketId).exec();
|
||||
}
|
||||
|
||||
async findUserTicket(ticketId: string, userId: string) {
|
||||
return this.ticketModel
|
||||
.findOne({ _id: new Types.ObjectId(ticketId), userId: new Types.ObjectId(userId) })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findUserTickets(filter: FilterQuery<SupportTicketDocument>, skip: number, limit: number, sort: Record<string, 1 | -1>) {
|
||||
return this.ticketModel.find(filter).sort(sort).skip(skip).limit(limit).exec();
|
||||
}
|
||||
|
||||
async findAdminTickets(filter: FilterQuery<SupportTicketDocument>, skip: number, limit: number, sort: Record<string, 1 | -1>) {
|
||||
return this.ticketModel
|
||||
.find(filter)
|
||||
.populate({ path: 'userId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.populate({ path: 'assignedAdminId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.sort(sort)
|
||||
.skip(skip)
|
||||
.limit(limit)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async findAdminTicket(ticketId: string) {
|
||||
return this.ticketModel
|
||||
.findById(ticketId)
|
||||
.populate({ path: 'userId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.populate({ path: 'assignedAdminId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async countTickets(filter: FilterQuery<SupportTicketDocument>) {
|
||||
return this.ticketModel.countDocuments(filter).exec();
|
||||
}
|
||||
|
||||
async findMessages(ticketId: string) {
|
||||
return this.messageModel
|
||||
.find({ ticketId: new Types.ObjectId(ticketId) })
|
||||
.populate({ path: 'senderId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.sort({ createdAt: 1 })
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateTicketAfterMessage(ticketId: string, updates: Partial<{ status: SupportTicketStatus }>) {
|
||||
const now = new Date();
|
||||
return this.ticketModel
|
||||
.findByIdAndUpdate(
|
||||
ticketId,
|
||||
{
|
||||
$set: {
|
||||
lastMessageAt: now,
|
||||
...updates,
|
||||
},
|
||||
},
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async closeTicket(ticketId: string, userId: string) {
|
||||
return this.ticketModel
|
||||
.findOneAndUpdate(
|
||||
{ _id: new Types.ObjectId(ticketId), userId: new Types.ObjectId(userId) },
|
||||
{ $set: { status: 'closed', closedAt: new Date() } },
|
||||
{ new: true },
|
||||
)
|
||||
.exec();
|
||||
}
|
||||
|
||||
async updateStatus(ticketId: string, status: SupportTicketStatus) {
|
||||
const update: Record<string, unknown> = { status };
|
||||
if (status === 'resolved') {
|
||||
update.resolvedAt = new Date();
|
||||
}
|
||||
if (status === 'closed') {
|
||||
update.closedAt = new Date();
|
||||
}
|
||||
return this.ticketModel.findByIdAndUpdate(ticketId, { $set: update }, { new: true }).exec();
|
||||
}
|
||||
|
||||
async assign(ticketId: string, adminId?: string | null) {
|
||||
return this.ticketModel
|
||||
.findByIdAndUpdate(
|
||||
ticketId,
|
||||
{ $set: { assignedAdminId: adminId && Types.ObjectId.isValid(adminId) ? new Types.ObjectId(adminId) : null } },
|
||||
{ new: true },
|
||||
)
|
||||
.populate({ path: 'userId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.populate({ path: 'assignedAdminId', select: 'name username stageName avatar email isDisabled isVerified' })
|
||||
.exec();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,266 @@
|
||||
import { BadRequestException, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { SupportService } from './support.service';
|
||||
|
||||
const userId = '507f1f77bcf86cd799439011';
|
||||
const otherUserId = '507f191e810c19729de860ea';
|
||||
const ticketId = '64f1f77bcf86cd799439011a';
|
||||
const adminId = '507f191e810c19729de860eb';
|
||||
|
||||
const asDoc = <T extends Record<string, any>>(payload: T) => ({
|
||||
id: payload._id?.toString?.() ?? payload._id,
|
||||
...payload,
|
||||
toObject: () => payload,
|
||||
});
|
||||
|
||||
describe('SupportService', () => {
|
||||
let service: SupportService;
|
||||
let repository: Record<string, jest.Mock>;
|
||||
let storage: Record<string, jest.Mock>;
|
||||
let notifications: Record<string, jest.Mock>;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = {
|
||||
createTicket: jest.fn(),
|
||||
createMessage: jest.fn(),
|
||||
findUserTickets: jest.fn(),
|
||||
countTickets: jest.fn(),
|
||||
findUserTicket: jest.fn(),
|
||||
findMessages: jest.fn(),
|
||||
closeTicket: jest.fn(),
|
||||
findAdminTickets: jest.fn(),
|
||||
findAdminTicket: jest.fn(),
|
||||
findTicketById: jest.fn(),
|
||||
updateTicketAfterMessage: jest.fn(),
|
||||
updateStatus: jest.fn(),
|
||||
assign: jest.fn(),
|
||||
};
|
||||
storage = {
|
||||
saveFile: jest.fn(),
|
||||
};
|
||||
notifications = {
|
||||
create: jest.fn(),
|
||||
};
|
||||
service = new SupportService(repository as any, storage as any, notifications as any);
|
||||
});
|
||||
|
||||
it('creates a support ticket without image and first user message', async () => {
|
||||
const ticket = asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
subject: 'Upload problem',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
lastMessageAt: new Date(),
|
||||
resolvedAt: null,
|
||||
closedAt: null,
|
||||
});
|
||||
repository.createTicket.mockResolvedValue(ticket);
|
||||
repository.createMessage.mockResolvedValue(asDoc({ _id: new Types.ObjectId(), ticketId }));
|
||||
|
||||
const result = await service.createTicket(userId, {
|
||||
subject: 'Upload problem',
|
||||
message: 'File upload failed',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
});
|
||||
|
||||
expect(result.message).toBe('Support ticket created successfully');
|
||||
expect(result.item.status).toBe('open');
|
||||
expect(repository.createMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
ticketId,
|
||||
senderId: userId,
|
||||
senderRole: 'user',
|
||||
message: 'File upload failed',
|
||||
attachmentUrl: '',
|
||||
attachmentType: null,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('stores optional image attachment for the first message', async () => {
|
||||
storage.saveFile.mockResolvedValue('/uploads/support/images/support-test.jpg');
|
||||
repository.createTicket.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
subject: 'Image problem',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
repository.createMessage.mockResolvedValue(asDoc({ _id: new Types.ObjectId(), ticketId }));
|
||||
|
||||
await service.createTicket(
|
||||
userId,
|
||||
{ subject: 'Image problem', message: 'See image' },
|
||||
{ mimetype: 'image/jpeg', size: 10, buffer: Buffer.from('img'), originalname: 'a.jpg' },
|
||||
);
|
||||
|
||||
expect(storage.saveFile).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
folderSegments: ['support', 'images'],
|
||||
extension: '.jpg',
|
||||
contentType: 'image/jpeg',
|
||||
}),
|
||||
);
|
||||
expect(repository.createMessage).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
attachmentUrl: '/uploads/support/images/support-test.jpg',
|
||||
attachmentType: 'image',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('lists only the current user tickets with pagination', async () => {
|
||||
const ticket = asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
subject: 'My ticket',
|
||||
category: 'technical',
|
||||
priority: 'normal',
|
||||
status: 'open',
|
||||
});
|
||||
repository.findUserTickets.mockResolvedValue([ticket]);
|
||||
repository.countTickets.mockResolvedValue(1);
|
||||
|
||||
const result = await service.listMyTickets(userId, { page: 1, limit: 20 });
|
||||
|
||||
expect(repository.findUserTickets).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ userId: expect.any(Types.ObjectId) }),
|
||||
0,
|
||||
20,
|
||||
{ lastMessageAt: -1 },
|
||||
);
|
||||
expect(result.items).toHaveLength(1);
|
||||
expect(result.pagination.total).toBe(1);
|
||||
});
|
||||
|
||||
it('blocks access to another user ticket', async () => {
|
||||
repository.findUserTicket.mockResolvedValue(null);
|
||||
|
||||
await expect(service.getMyTicket(userId, ticketId)).rejects.toBeInstanceOf(NotFoundException);
|
||||
});
|
||||
|
||||
it('adds a user message to an owned open ticket', async () => {
|
||||
repository.findUserTicket.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
repository.createMessage.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(),
|
||||
ticketId: new Types.ObjectId(ticketId),
|
||||
senderId: new Types.ObjectId(userId),
|
||||
senderRole: 'user',
|
||||
message: 'More details',
|
||||
attachmentUrl: '',
|
||||
}),
|
||||
);
|
||||
|
||||
const result = await service.addUserMessage(userId, ticketId, { message: 'More details' });
|
||||
|
||||
expect(result.item.message).toBe('More details');
|
||||
expect(repository.updateTicketAfterMessage).toHaveBeenCalledWith(ticketId, {});
|
||||
});
|
||||
|
||||
it('rejects empty user messages without image', async () => {
|
||||
repository.findUserTicket.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
|
||||
await expect(service.addUserMessage(userId, ticketId, { message: '' })).rejects.toBeInstanceOf(
|
||||
BadRequestException,
|
||||
);
|
||||
});
|
||||
|
||||
it('admin list returns populated user details', async () => {
|
||||
repository.findAdminTickets.mockResolvedValue([
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: {
|
||||
_id: new Types.ObjectId(userId),
|
||||
name: 'User One',
|
||||
username: 'userone',
|
||||
email: 'user@example.com',
|
||||
avatar: '',
|
||||
},
|
||||
subject: 'Need help',
|
||||
category: 'technical',
|
||||
priority: 'high',
|
||||
status: 'open',
|
||||
}),
|
||||
]);
|
||||
repository.countTickets.mockResolvedValue(1);
|
||||
|
||||
const result = await service.listAdminTickets({ page: 1, limit: 20, status: 'open' });
|
||||
|
||||
expect(result.items[0].user?.email).toBe('user@example.com');
|
||||
expect(result.items[0].user?.username).toBe('userone');
|
||||
});
|
||||
|
||||
it('admin reply updates open ticket to in_progress and notifies when actor is a user id', async () => {
|
||||
repository.findTicketById.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(otherUserId),
|
||||
status: 'open',
|
||||
}),
|
||||
);
|
||||
repository.createMessage.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(),
|
||||
ticketId: new Types.ObjectId(ticketId),
|
||||
senderId: new Types.ObjectId(adminId),
|
||||
senderRole: 'admin',
|
||||
message: 'We are checking',
|
||||
attachmentUrl: '',
|
||||
}),
|
||||
);
|
||||
|
||||
await service.addAdminMessage(adminId, ticketId, { message: 'We are checking' });
|
||||
|
||||
expect(repository.updateTicketAfterMessage).toHaveBeenCalledWith(ticketId, { status: 'in_progress' });
|
||||
expect(notifications.create).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
actorId: adminId,
|
||||
recipientId: otherUserId,
|
||||
type: 'support_reply',
|
||||
resourceType: 'support_ticket',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('updates status and assignment', async () => {
|
||||
repository.updateStatus.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
status: 'resolved',
|
||||
}),
|
||||
);
|
||||
repository.assign.mockResolvedValue(
|
||||
asDoc({
|
||||
_id: new Types.ObjectId(ticketId),
|
||||
userId: new Types.ObjectId(userId),
|
||||
assignedAdminId: new Types.ObjectId(adminId),
|
||||
}),
|
||||
);
|
||||
|
||||
const status = await service.updateAdminStatus(ticketId, { status: 'resolved' });
|
||||
const assigned = await service.assignAdmin(ticketId, { adminId });
|
||||
|
||||
expect(status.item.status).toBe('resolved');
|
||||
expect(assigned.item.assignedAdminId).toBe(adminId);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,396 @@
|
||||
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
|
||||
import { Types } from 'mongoose';
|
||||
import { extname } from 'path';
|
||||
import { buildPaginatedResponse } from '../../common/utils/pagination.util';
|
||||
import { resolveManagedFileUrl } from '../../common/utils/public-url.util';
|
||||
import { resolveMongoSortDirection } from '../../common/utils/sort.util';
|
||||
import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service';
|
||||
import { NotificationsService } from '../notifications/notifications.service';
|
||||
import { AssignSupportTicketDto } from './dto/assign-support-ticket.dto';
|
||||
import { CreateSupportMessageDto } from './dto/create-support-message.dto';
|
||||
import { CreateSupportTicketDto } from './dto/create-support-ticket.dto';
|
||||
import { SupportTicketQueryDto } from './dto/support-ticket-query.dto';
|
||||
import { UpdateSupportTicketStatusDto } from './dto/update-support-ticket-status.dto';
|
||||
import { SupportRepository } from './support.repository';
|
||||
import { SupportTicketDocument, SupportTicketStatus } from './schemas/support-ticket.schema';
|
||||
import { SupportTicketMessageDocument } from './schemas/support-ticket-message.schema';
|
||||
|
||||
type UploadFile = {
|
||||
mimetype?: string;
|
||||
size: number;
|
||||
buffer: Buffer;
|
||||
originalname?: string;
|
||||
};
|
||||
|
||||
@Injectable()
|
||||
export class SupportService {
|
||||
private readonly logger = new Logger(SupportService.name);
|
||||
private readonly maxImageSizeBytes = 5 * 1024 * 1024;
|
||||
|
||||
constructor(
|
||||
private readonly supportRepository: SupportRepository,
|
||||
private readonly storageService: ManagedStorageService,
|
||||
private readonly notificationsService: NotificationsService,
|
||||
) {}
|
||||
|
||||
async createTicket(userId: string, dto: CreateSupportTicketDto, image?: UploadFile) {
|
||||
this.assertValidUserId(userId);
|
||||
const subject = dto.subject.trim();
|
||||
const message = dto.message.trim();
|
||||
const attachmentUrl = await this.saveOptionalImage(image);
|
||||
const now = new Date();
|
||||
const ticket = await this.supportRepository.createTicket({
|
||||
userId,
|
||||
subject,
|
||||
category: dto.category ?? 'technical',
|
||||
priority: dto.priority ?? 'normal',
|
||||
lastMessageAt: now,
|
||||
});
|
||||
|
||||
await this.supportRepository.createMessage({
|
||||
ticketId: ticket.id,
|
||||
senderId: userId,
|
||||
senderIdentifier: userId,
|
||||
senderRole: 'user',
|
||||
message,
|
||||
attachmentUrl,
|
||||
attachmentType: attachmentUrl ? 'image' : null,
|
||||
readByUser: true,
|
||||
readByAdmin: false,
|
||||
});
|
||||
|
||||
return {
|
||||
message: 'Support ticket created successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
async listMyTickets(userId: string, query: SupportTicketQueryDto) {
|
||||
this.assertValidUserId(userId);
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter = this.buildTicketFilter(query, { userId });
|
||||
const sort = { lastMessageAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.supportRepository.findUserTickets(filter, skip, limit, sort),
|
||||
this.supportRepository.countTickets(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(
|
||||
items.map((ticket) => this.mapTicket(ticket)),
|
||||
{ page, limit, total, offset: skip },
|
||||
);
|
||||
}
|
||||
|
||||
async getMyTicket(userId: string, ticketId: string) {
|
||||
const ticket = await this.getOwnedTicketOrThrow(userId, ticketId);
|
||||
const messages = await this.supportRepository.findMessages(ticket.id);
|
||||
return {
|
||||
ticket: this.mapTicket(ticket),
|
||||
messages: messages.map((message) => this.mapMessage(message)),
|
||||
};
|
||||
}
|
||||
|
||||
async addUserMessage(userId: string, ticketId: string, dto: CreateSupportMessageDto, image?: UploadFile) {
|
||||
const ticket = await this.getOwnedTicketOrThrow(userId, ticketId);
|
||||
if (ticket.status === 'closed') {
|
||||
throw new BadRequestException('Cannot add messages to a closed support ticket');
|
||||
}
|
||||
|
||||
const attachmentUrl = await this.saveOptionalImage(image);
|
||||
const messageText = (dto.message ?? '').trim();
|
||||
this.assertMessageOrAttachment(messageText, attachmentUrl);
|
||||
|
||||
const message = await this.supportRepository.createMessage({
|
||||
ticketId: ticket.id,
|
||||
senderId: userId,
|
||||
senderIdentifier: userId,
|
||||
senderRole: 'user',
|
||||
message: messageText,
|
||||
attachmentUrl,
|
||||
attachmentType: attachmentUrl ? 'image' : null,
|
||||
readByUser: true,
|
||||
readByAdmin: false,
|
||||
});
|
||||
await this.supportRepository.updateTicketAfterMessage(ticket.id, {});
|
||||
|
||||
return {
|
||||
message: 'Support message added successfully',
|
||||
item: this.mapMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
async closeMyTicket(userId: string, ticketId: string) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
this.assertValidUserId(userId);
|
||||
const ticket = await this.supportRepository.closeTicket(ticketId, userId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return {
|
||||
message: 'Support ticket closed successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
async listAdminTickets(query: SupportTicketQueryDto) {
|
||||
const page = query.page ?? 1;
|
||||
const limit = query.limit ?? 20;
|
||||
const skip = (page - 1) * limit;
|
||||
const filter = this.buildTicketFilter(query);
|
||||
const sort = { lastMessageAt: resolveMongoSortDirection(query.sortOrder) } as Record<string, 1 | -1>;
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.supportRepository.findAdminTickets(filter, skip, limit, sort),
|
||||
this.supportRepository.countTickets(filter),
|
||||
]);
|
||||
|
||||
return buildPaginatedResponse(
|
||||
items.map((ticket) => this.mapTicket(ticket)),
|
||||
{ page, limit, total, offset: skip },
|
||||
);
|
||||
}
|
||||
|
||||
async getAdminTicket(ticketId: string) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
const ticket = await this.supportRepository.findAdminTicket(ticketId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
const messages = await this.supportRepository.findMessages(ticket.id);
|
||||
return {
|
||||
ticket: this.mapTicket(ticket),
|
||||
messages: messages.map((message) => this.mapMessage(message)),
|
||||
};
|
||||
}
|
||||
|
||||
async addAdminMessage(
|
||||
adminIdentifier: string,
|
||||
ticketId: string,
|
||||
dto: CreateSupportMessageDto,
|
||||
image?: UploadFile,
|
||||
) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
const ticket = await this.supportRepository.findTicketById(ticketId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
if (ticket.status === 'closed') {
|
||||
throw new BadRequestException('Cannot add messages to a closed support ticket');
|
||||
}
|
||||
|
||||
const attachmentUrl = await this.saveOptionalImage(image);
|
||||
const messageText = (dto.message ?? '').trim();
|
||||
this.assertMessageOrAttachment(messageText, attachmentUrl);
|
||||
const adminObjectId = Types.ObjectId.isValid(adminIdentifier) ? adminIdentifier : null;
|
||||
const message = await this.supportRepository.createMessage({
|
||||
ticketId: ticket.id,
|
||||
senderId: adminObjectId,
|
||||
senderIdentifier: adminIdentifier,
|
||||
senderRole: adminObjectId ? 'admin' : 'superadmin',
|
||||
message: messageText,
|
||||
attachmentUrl,
|
||||
attachmentType: attachmentUrl ? 'image' : null,
|
||||
readByUser: false,
|
||||
readByAdmin: true,
|
||||
});
|
||||
|
||||
const nextStatus: Partial<{ status: SupportTicketStatus }> =
|
||||
ticket.status === 'open' ? { status: 'in_progress' } : {};
|
||||
await this.supportRepository.updateTicketAfterMessage(ticket.id, nextStatus);
|
||||
await this.notifyUserAboutAdminReply(adminObjectId, ticket.userId.toString(), ticket.id, messageText);
|
||||
|
||||
return {
|
||||
message: 'Support reply sent successfully',
|
||||
item: this.mapMessage(message),
|
||||
};
|
||||
}
|
||||
|
||||
async updateAdminStatus(ticketId: string, dto: UpdateSupportTicketStatusDto) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
const ticket = await this.supportRepository.updateStatus(ticketId, dto.status);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return {
|
||||
message: 'Support ticket status updated successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
async assignAdmin(ticketId: string, dto: AssignSupportTicketDto) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
if (dto.adminId) {
|
||||
this.assertValidUserId(dto.adminId);
|
||||
}
|
||||
const ticket = await this.supportRepository.assign(ticketId, dto.adminId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return {
|
||||
message: 'Support ticket assignment updated successfully',
|
||||
item: this.mapTicket(ticket),
|
||||
};
|
||||
}
|
||||
|
||||
private buildTicketFilter(query: SupportTicketQueryDto, base?: { userId?: string }) {
|
||||
const filter: Record<string, unknown> = {};
|
||||
if (base?.userId) {
|
||||
filter.userId = new Types.ObjectId(base.userId);
|
||||
}
|
||||
if (query.userId) {
|
||||
filter.userId = new Types.ObjectId(query.userId);
|
||||
}
|
||||
if (query.status) {
|
||||
filter.status = query.status;
|
||||
}
|
||||
if (query.category) {
|
||||
filter.category = query.category;
|
||||
}
|
||||
if (query.priority) {
|
||||
filter.priority = query.priority;
|
||||
}
|
||||
return filter;
|
||||
}
|
||||
|
||||
private async getOwnedTicketOrThrow(userId: string, ticketId: string) {
|
||||
this.assertValidTicketId(ticketId);
|
||||
this.assertValidUserId(userId);
|
||||
const ticket = await this.supportRepository.findUserTicket(ticketId, userId);
|
||||
if (!ticket) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
return ticket;
|
||||
}
|
||||
|
||||
private async saveOptionalImage(file?: UploadFile): Promise<string> {
|
||||
if (!file) {
|
||||
return '';
|
||||
}
|
||||
if (!file.mimetype?.startsWith('image/')) {
|
||||
throw new BadRequestException('Support attachment must be an image');
|
||||
}
|
||||
if (file.size > this.maxImageSizeBytes) {
|
||||
throw new BadRequestException('Support image must be 5MB or smaller');
|
||||
}
|
||||
return this.storageService.saveFile({
|
||||
folderSegments: ['support', 'images'],
|
||||
extension: this.resolveImageExtension(file),
|
||||
buffer: file.buffer,
|
||||
contentType: file.mimetype,
|
||||
fileNamePrefix: 'support',
|
||||
});
|
||||
}
|
||||
|
||||
private resolveImageExtension(file: UploadFile): string {
|
||||
const extension = extname(file.originalname ?? '').toLowerCase();
|
||||
if (['.jpg', '.jpeg', '.png', '.webp', '.gif'].includes(extension)) {
|
||||
return extension;
|
||||
}
|
||||
if (file.mimetype === 'image/png') {
|
||||
return '.png';
|
||||
}
|
||||
if (file.mimetype === 'image/webp') {
|
||||
return '.webp';
|
||||
}
|
||||
if (file.mimetype === 'image/gif') {
|
||||
return '.gif';
|
||||
}
|
||||
return '.jpg';
|
||||
}
|
||||
|
||||
private assertMessageOrAttachment(message: string, attachmentUrl: string) {
|
||||
if (!message && !attachmentUrl) {
|
||||
throw new BadRequestException('message or image is required');
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidTicketId(ticketId: string) {
|
||||
if (!Types.ObjectId.isValid(ticketId)) {
|
||||
throw new NotFoundException('Support ticket not found');
|
||||
}
|
||||
}
|
||||
|
||||
private assertValidUserId(userId: string) {
|
||||
if (!Types.ObjectId.isValid(userId)) {
|
||||
throw new BadRequestException('Invalid user id');
|
||||
}
|
||||
}
|
||||
|
||||
private mapTicket(ticket: SupportTicketDocument) {
|
||||
const object = ticket.toObject();
|
||||
const user = this.normalizePopulatedUser(object.userId);
|
||||
const assignedAdmin = this.normalizePopulatedUser(object.assignedAdminId);
|
||||
return {
|
||||
...object,
|
||||
_id: object._id?.toString(),
|
||||
userId: user?._id ?? object.userId?.toString(),
|
||||
assignedAdminId: assignedAdmin?._id ?? object.assignedAdminId?.toString?.() ?? null,
|
||||
user,
|
||||
assignedAdmin,
|
||||
};
|
||||
}
|
||||
|
||||
private mapMessage(message: SupportTicketMessageDocument) {
|
||||
const object = message.toObject();
|
||||
const sender = this.normalizePopulatedUser(object.senderId);
|
||||
return {
|
||||
...object,
|
||||
_id: object._id?.toString(),
|
||||
ticketId: object.ticketId?.toString(),
|
||||
senderId: sender?._id ?? object.senderId?.toString?.() ?? null,
|
||||
sender,
|
||||
attachmentUrl: resolveManagedFileUrl(object.attachmentUrl),
|
||||
};
|
||||
}
|
||||
|
||||
private normalizePopulatedUser(value: unknown) {
|
||||
if (!value || typeof value !== 'object') {
|
||||
return null;
|
||||
}
|
||||
const user = value as Record<string, any>;
|
||||
if (!user._id) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
_id: user._id.toString(),
|
||||
name: user.name ?? '',
|
||||
username: user.username ?? '',
|
||||
stageName: user.stageName ?? '',
|
||||
avatar: resolveManagedFileUrl(user.avatar ?? ''),
|
||||
email: user.email ?? '',
|
||||
isDisabled: !!user.isDisabled,
|
||||
isVerified: !!user.isVerified,
|
||||
};
|
||||
}
|
||||
|
||||
private async notifyUserAboutAdminReply(
|
||||
actorId: string | null,
|
||||
recipientId: string,
|
||||
ticketId: string,
|
||||
previewText: string,
|
||||
) {
|
||||
if (!actorId || !Types.ObjectId.isValid(actorId)) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await this.notificationsService.create({
|
||||
actorId,
|
||||
recipientId,
|
||||
type: 'support_reply',
|
||||
referenceId: ticketId,
|
||||
resourceType: 'support_ticket',
|
||||
deepLink: `/support/tickets/${ticketId}`,
|
||||
previewText: previewText.slice(0, 160),
|
||||
});
|
||||
} catch (error) {
|
||||
this.logger.warn(
|
||||
`Support reply notification failed for ticket=${ticketId}: ${
|
||||
error instanceof Error ? error.message : 'unknown error'
|
||||
}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user