Support comment updates and multipart post uploads
هذا الالتزام موجود في:
29
src/common/guards/multipart-form-data.guard.spec.ts
Normal file
29
src/common/guards/multipart-form-data.guard.spec.ts
Normal file
@@ -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);
|
||||
});
|
||||
});
|
||||
20
src/common/guards/multipart-form-data.guard.ts
Normal file
20
src/common/guards/multipart-form-data.guard.ts
Normal file
@@ -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,
|
||||
|
||||
81
src/modules/comments/comments.service.spec.ts
Normal file
81
src/modules/comments/comments.service.spec.ts
Normal file
@@ -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 },
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم