feat: add support tickets backend and dashboard

This commit is contained in:
boutmoun123
2026-06-07 02:10:31 +03:00
parent d373d576e3
commit 48dc3861cf
20 changed files with 1811 additions and 0 deletions
+2
View File
@@ -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 });
+159
View File
@@ -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);
}
}
+25
View File
@@ -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 {}
+156
View File
@@ -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();
}
}
+266
View File
@@ -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);
});
});
+396
View File
@@ -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'
}`,
);
}
}
}