Support comment updates and multipart post uploads

هذا الالتزام موجود في:
2026-05-16 01:54:52 +03:00
الأصل 160bb27a59
التزام 045c74014c
11 ملفات معدلة مع 534 إضافات و48 حذوفات

عرض الملف

@@ -0,0 +1,29 @@
import { ExecutionContext, UnsupportedMediaTypeException } from '@nestjs/common';
import { MultipartFormDataGuard } from './multipart-form-data.guard';
describe('MultipartFormDataGuard', () => {
const guard = new MultipartFormDataGuard();
const createContext = (contentType: string | null): ExecutionContext =>
({
switchToHttp: () => ({
getRequest: () => ({
is: (type: string) => (contentType === type ? contentType : false),
}),
}),
}) as ExecutionContext;
it('allows multipart/form-data requests', () => {
expect(guard.canActivate(createContext('multipart/form-data'))).toBe(true);
});
it('rejects non-multipart requests', () => {
expect(() => guard.canActivate(createContext('application/json'))).toThrow(
new UnsupportedMediaTypeException('Content-Type must be multipart/form-data'),
);
});
it('rejects requests without a content type match', () => {
expect(() => guard.canActivate(createContext(null))).toThrow(UnsupportedMediaTypeException);
});
});

عرض الملف

@@ -0,0 +1,20 @@
import {
CanActivate,
ExecutionContext,
Injectable,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { Request } from 'express';
@Injectable()
export class MultipartFormDataGuard implements CanActivate {
canActivate(context: ExecutionContext): boolean {
const request = context.switchToHttp().getRequest<Request>();
if (request.is('multipart/form-data')) {
return true;
}
throw new UnsupportedMediaTypeException('Content-Type must be multipart/form-data');
}
}

عرض الملف

@@ -1,4 +1,4 @@
import { Controller, Delete, Get, Param, Post, Query, Body, UseGuards } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Patch, Post, Query, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { SuperAdminPermissions } from '../../common/decorators/superadmin-permissions.decorator';
@@ -9,6 +9,7 @@ import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentsService } from './comments.service';
import { SUPERADMIN_PERMISSIONS } from '../superadmin/superadmin-permissions';
@@ -38,6 +39,17 @@ export class CommentsController {
return this.commentsService.findReplies(commentId, query);
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@Patch(':commentId')
async update(
@CurrentUser() user: JwtPayload,
@Param('commentId') commentId: string,
@Body() dto: UpdateCommentDto,
) {
return this.commentsService.update(user.sub, commentId, dto);
}
@ApiBearerAuth()
@UseGuards(SuperAdminJwtAuthGuard, SuperAdminPermissionsGuard)
@SuperAdminPermissions(SUPERADMIN_PERMISSIONS.CONTENT_MODERATE)

عرض الملف

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { InjectModel } from '@nestjs/mongoose';
import { ClientSession, FilterQuery, Model, Types } from 'mongoose';
import { ClientSession, FilterQuery, Model, Types, UpdateQuery } from 'mongoose';
import { ModerationStatus } from '../../common/enums/moderation-status.enum';
import { Comment, CommentDocument } from './schemas/comment.schema';
@@ -73,6 +73,22 @@ export class CommentsRepository {
return !!updated;
}
async updateById(
commentId: string,
payload: UpdateQuery<Pick<Comment, 'content' | 'mentionUsernames'>>,
): Promise<CommentDocument | null> {
if (!Types.ObjectId.isValid(commentId)) {
return null;
}
return this.commentModel
.findOneAndUpdate({ _id: new Types.ObjectId(commentId), isDeleted: { $ne: true } }, payload, {
new: true,
})
.populate({ path: 'authorId', select: 'name username avatar stageName isVerified' })
.exec();
}
async findMany(
filter: FilterQuery<CommentDocument>,
skip: number,

عرض الملف

@@ -0,0 +1,81 @@
import { Types } from 'mongoose';
import { CommentsService } from './comments.service';
describe('CommentsService', () => {
it('updates own comment and notifies newly mentioned users', async () => {
const userId = new Types.ObjectId().toString();
const postId = new Types.ObjectId().toString();
const commentId = new Types.ObjectId().toString();
const updatedContent = 'Edited comment @new_mention';
const commentsRepository = {
findById: jest.fn().mockResolvedValue({
_id: new Types.ObjectId(commentId),
authorId: new Types.ObjectId(userId),
postId: new Types.ObjectId(postId),
content: 'Original comment',
mentionUsernames: ['old_mention'],
}),
updateById: jest.fn().mockResolvedValue({
_id: new Types.ObjectId(commentId),
authorId: new Types.ObjectId(userId),
postId: new Types.ObjectId(postId),
content: updatedContent,
mentionUsernames: ['new_mention'],
}),
};
const postsRepository = {
findById: jest.fn(),
setCommentsCount: jest.fn(),
};
const auditService = {
logSuperAdminAction: jest.fn(),
};
const feedVersionService = {
bumpGlobalVersion: jest.fn(),
};
const notificationsService = {
createCommentNotification: jest.fn(),
createMentionNotification: jest.fn(),
};
const usersRepository = {
findByUsernames: jest.fn().mockResolvedValue([{ id: 'mentioned-user-id', username: 'new_mention' }]),
};
const service = new CommentsService(
commentsRepository as any,
postsRepository as any,
auditService as any,
feedVersionService as any,
notificationsService as any,
usersRepository as any,
);
const result = await service.update(userId, commentId, {
content: updatedContent,
mentionUsernames: ['new_mention'],
});
expect(commentsRepository.updateById).toHaveBeenCalledWith(commentId, {
content: updatedContent,
mentionUsernames: ['new_mention'],
});
expect(feedVersionService.bumpGlobalVersion).toHaveBeenCalled();
expect(notificationsService.createMentionNotification).toHaveBeenCalledWith(
userId,
'mentioned-user-id',
postId,
{
resourceType: 'comment',
previewText: updatedContent,
deepLink: `/posts/${postId}`,
},
);
expect(result).toEqual(
expect.objectContaining({
content: updatedContent,
mentionUsernames: ['new_mention'],
}),
);
});
});

عرض الملف

@@ -11,6 +11,7 @@ import { UsersRepository } from '../users/users.repository';
import { AdminCommentQueryDto } from './dto/admin-comment-query.dto';
import { CommentQueryDto } from './dto/comment-query.dto';
import { CreateCommentDto } from './dto/create-comment.dto';
import { UpdateCommentDto } from './dto/update-comment.dto';
import { CommentsRepository } from './comments.repository';
@Injectable()
@@ -87,6 +88,54 @@ export class CommentsService {
return { success: true };
}
async update(userId: string, commentId: string, dto: UpdateCommentDto) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {
throw new NotFoundException('Comment not found');
}
if (comment.authorId.toString() !== userId) {
throw new ForbiddenException('You can only update your own comments');
}
const hasContentUpdate = typeof dto.content === 'string';
const hasMentionUpdate = typeof dto.mentionUsernames !== 'undefined';
if (!hasContentUpdate && !hasMentionUpdate) {
throw new BadRequestException('Nothing to update');
}
const nextContent = hasContentUpdate ? dto.content!.trim() : comment.content;
if (!nextContent) {
throw new BadRequestException('Comment content cannot be empty');
}
const previousMentionUsernames = this.normalizeMentionUsernames(comment.mentionUsernames ?? []);
const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, nextContent, userId);
const updated = await this.commentsRepository.updateById(commentId, {
content: nextContent,
mentionUsernames: mentionResolution.mentionUsernames,
});
if (!updated) {
throw new NotFoundException('Comment not found');
}
await this.feedVersionService.bumpGlobalVersion();
const previousMentionSet = new Set(previousMentionUsernames);
const nextMentionedUsers = mentionResolution.mentionedUsers.filter(
(mentionedUser) => !previousMentionSet.has(mentionedUser.username),
);
await this.notifyMentionedUsers(
userId,
comment.postId.toString(),
nextMentionedUsers,
nextContent.slice(0, 160),
new Set<string>(),
);
return updated;
}
async removeBySuperAdmin(superAdminIdentifier: string, commentId: string) {
const comment = await this.commentsRepository.findById(commentId);
if (!comment) {

عرض الملف

@@ -1,8 +1,21 @@
import { IsOptional, IsString, Length } from 'class-validator';
import { ApiPropertyOptional } from '@nestjs/swagger';
import { Transform } from 'class-transformer';
import { ArrayMaxSize, IsArray, IsOptional, IsString, Length } from 'class-validator';
import { toStringArray } from '../../../common/utils/array-transform.util';
export class UpdateCommentDto {
@ApiPropertyOptional({ maxLength: 1000 })
@IsOptional()
@IsString()
@Length(1, 1000)
content?: string;
@ApiPropertyOptional({ type: [String], description: 'Set mention usernames like rami_sabry (max 30)' })
@IsOptional()
@Transform(toStringArray)
@IsArray()
@ArrayMaxSize(30)
@IsString({ each: true })
@Length(1, 30, { each: true })
mentionUsernames?: string[];
}

عرض الملف

@@ -17,6 +17,7 @@ import { FileFieldsInterceptor } from '@nestjs/platform-express';
import { ApiBearerAuth, ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import { CurrentUser } from '../../common/decorators/current-user.decorator';
import { JwtAuthGuard } from '../../common/guards/jwt-auth.guard';
import { MultipartFormDataGuard } from '../../common/guards/multipart-form-data.guard';
import { SuperAdminPermissionsGuard } from '../../common/guards/superadmin-permissions.guard';
import { SuperAdminJwtAuthGuard } from '../../common/guards/super-admin-jwt-auth.guard';
import { JwtPayload } from '../../common/interfaces/jwt-payload.interface';
@@ -36,7 +37,7 @@ export class PostsController {
constructor(private readonly postsService: PostsService) {}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, MultipartFormDataGuard)
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'imageFiles', maxCount: 10 },
@@ -107,7 +108,7 @@ export class PostsController {
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, MultipartFormDataGuard)
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'videoFile', maxCount: 1 },
@@ -158,7 +159,7 @@ export class PostsController {
}
@ApiBearerAuth()
@UseGuards(JwtAuthGuard)
@UseGuards(JwtAuthGuard, MultipartFormDataGuard)
@UseInterceptors(
FileFieldsInterceptor([
{ name: 'imageFiles', maxCount: 10 },