Support comment updates and multipart post uploads
هذا الالتزام موجود في:
@@ -105,6 +105,8 @@ Supported filters:
|
||||
- `read`, `type`, `resourceType`, `sortOrder`
|
||||
- `GET /comments/post/:postId`
|
||||
- `page`, `limit`, `sortOrder`
|
||||
- `PATCH /comments/:commentId`
|
||||
- JSON body: `content`, `mentionUsernames`
|
||||
|
||||
## WebSocket auth
|
||||
|
||||
@@ -171,6 +173,34 @@ Posts and comments support:
|
||||
|
||||
The backend also extracts `@username` from `content` automatically and emits mention notifications for matched users.
|
||||
|
||||
## Posts Upload Contract
|
||||
|
||||
Post creation and updates now require `multipart/form-data` for all post payloads, including text-only posts and posts that use external media links.
|
||||
|
||||
- `POST /posts`
|
||||
- `PATCH /posts/:postId`
|
||||
- `POST /posts/reels`
|
||||
|
||||
Send media uploads as files:
|
||||
|
||||
- `imageFiles`
|
||||
- `videoFile`
|
||||
- `audioFile`
|
||||
|
||||
Send link-based fields as text fields inside the same form-data payload:
|
||||
|
||||
- `imageUrls`
|
||||
- `videoUrl`
|
||||
- `audioUrl`
|
||||
- `thumbnailUrl`
|
||||
|
||||
Array fields may be sent either as repeated form keys or JSON text:
|
||||
|
||||
- `taggedUserIds`
|
||||
- `mentionUsernames`
|
||||
- `imageUrls`
|
||||
- `waveformPeaks`
|
||||
|
||||
## Marketplace split
|
||||
|
||||
Marketplace is now separated from musical instruments at the API contract level:
|
||||
|
||||
@@ -1341,10 +1341,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
@@ -1352,8 +1348,44 @@
|
||||
],
|
||||
"url": "{{baseUrl}}/posts",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"First text post with mention @{{targetUsername}} #music\",\n \"taggedUserIds\": [\"{{targetUserId}}\"],\n \"mentionUsernames\": [\"{{targetUsername}}\"],\n \"location\": \"Riyadh, Saudi Arabia\",\n \"latitude\": 24.7136,\n \"longitude\": 46.6753,\n \"visibility\": \"public\"\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "First text post with mention @{{targetUsername}} #music",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "taggedUserIds",
|
||||
"value": "{{targetUserId}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "mentionUsernames",
|
||||
"value": "{{targetUsername}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "location",
|
||||
"value": "Riyadh, Saudi Arabia",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "latitude",
|
||||
"value": "24.7136",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "longitude",
|
||||
"value": "46.6753",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "visibility",
|
||||
"value": "public",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
@@ -1689,10 +1721,6 @@
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
@@ -1700,8 +1728,39 @@
|
||||
],
|
||||
"url": "{{baseUrl}}/posts/{{postId}}",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"Updated post content with @{{targetUsername}} #live\",\n \"taggedUserIds\": [\"{{targetUserId}}\"],\n \"mentionUsernames\": [\"{{targetUsername}}\"],\n \"location\": \"Jeddah, Saudi Arabia\",\n \"latitude\": 21.5433,\n \"longitude\": 39.1728\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "Updated post content with @{{targetUsername}} #live",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "taggedUserIds",
|
||||
"value": "{{targetUserId}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "mentionUsernames",
|
||||
"value": "{{targetUsername}}",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "location",
|
||||
"value": "Jeddah, Saudi Arabia",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "latitude",
|
||||
"value": "21.5433",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "longitude",
|
||||
"value": "39.1728",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
@@ -2064,10 +2123,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
@@ -2075,8 +2130,54 @@
|
||||
],
|
||||
"url": "{{baseUrl}}/posts",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"Audio post with waveform #oud #hijaz\",\n \"audioUrl\": \"https://cdn.example.com/audio-sample.mp3\",\n \"durationSeconds\": 54,\n \"thumbnailUrl\": \"https://cdn.example.com/audio-cover.jpg\",\n \"style\": \"Sharqi\",\n \"maqam\": \"Hijaz\",\n \"rhythmSignature\": \"6/8\",\n \"waveformPeaks\": [12, 38, 27, 49, 22, 44],\n \"visibility\": \"public\"\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "Audio post with waveform #oud #hijaz",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "audioUrl",
|
||||
"value": "https://cdn.example.com/audio-sample.mp3",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "durationSeconds",
|
||||
"value": "54",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "thumbnailUrl",
|
||||
"value": "https://cdn.example.com/audio-cover.jpg",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "style",
|
||||
"value": "Sharqi",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "maqam",
|
||||
"value": "Hijaz",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "rhythmSignature",
|
||||
"value": "6/8",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "waveformPeaks",
|
||||
"value": "[12,38,27,49,22,44]",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "visibility",
|
||||
"value": "public",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
@@ -2140,10 +2241,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
@@ -2151,8 +2248,29 @@
|
||||
],
|
||||
"url": "{{baseUrl}}/posts",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"Invalid post\",\n \"videoUrl\": \"https://cdn.example.com/video.mp4\",\n \"audioUrl\": \"https://cdn.example.com/audio.mp3\",\n \"visibility\": \"public\"\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "Invalid post",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "videoUrl",
|
||||
"value": "https://cdn.example.com/video.mp4",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "audioUrl",
|
||||
"value": "https://cdn.example.com/audio.mp3",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "visibility",
|
||||
"value": "public",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
@@ -2528,6 +2646,40 @@
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Update Comment",
|
||||
"request": {
|
||||
"method": "PATCH",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/comments/{{commentId}}",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"Edited comment @{{secondaryUsername}}\",\n \"mentionUsernames\": [\"{{secondaryUsername}}\"]\n}"
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
{
|
||||
"listen": "test",
|
||||
"script": {
|
||||
"type": "text/javascript",
|
||||
"exec": [
|
||||
"pm.test(\u0027Status is 200\u0027, function () { pm.response.to.have.status(200); });",
|
||||
"const json = pm.response.json();",
|
||||
"pm.expect(json.content).to.include(\u0027Edited comment\u0027);"
|
||||
]
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"name": "Delete Comment",
|
||||
"request": {
|
||||
@@ -3812,10 +3964,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
@@ -3823,8 +3971,19 @@
|
||||
],
|
||||
"url": "{{baseUrl}}/posts",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"E2E text post\",\n \"visibility\": \"public\"\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "E2E text post",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "visibility",
|
||||
"value": "public",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -3914,10 +4073,6 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
},
|
||||
{
|
||||
"key": "Authorization",
|
||||
"value": "Bearer {{accessToken}}"
|
||||
@@ -3925,8 +4080,29 @@
|
||||
],
|
||||
"url": "{{baseUrl}}/posts",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"Invalid post\",\n \"videoUrl\": \"https://cdn.example.com/video.mp4\",\n \"audioUrl\": \"https://cdn.example.com/audio.mp3\",\n \"visibility\": \"public\"\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "Invalid post",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "videoUrl",
|
||||
"value": "https://cdn.example.com/video.mp4",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "audioUrl",
|
||||
"value": "https://cdn.example.com/audio.mp3",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "visibility",
|
||||
"value": "public",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
@@ -3946,15 +4122,22 @@
|
||||
"request": {
|
||||
"method": "POST",
|
||||
"header": [
|
||||
{
|
||||
"key": "Content-Type",
|
||||
"value": "application/json"
|
||||
}
|
||||
],
|
||||
"url": "{{baseUrl}}/posts",
|
||||
"body": {
|
||||
"mode": "raw",
|
||||
"raw": "{\n \"content\": \"No token request\",\n \"visibility\": \"public\"\n}"
|
||||
"mode": "formdata",
|
||||
"formdata": [
|
||||
{
|
||||
"key": "content",
|
||||
"value": "No token request",
|
||||
"type": "text"
|
||||
},
|
||||
{
|
||||
"key": "visibility",
|
||||
"value": "public",
|
||||
"type": "text"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"event": [
|
||||
|
||||
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 },
|
||||
|
||||
@@ -66,6 +66,7 @@ describe('Oudelaa smoke (e2e)', () => {
|
||||
secondRefreshToken: '',
|
||||
};
|
||||
let postId = '';
|
||||
let editableCommentId = '';
|
||||
|
||||
beforeAll(async () => {
|
||||
const moduleFixture: TestingModule = await Test.createTestingModule({
|
||||
@@ -270,6 +271,57 @@ describe('Oudelaa smoke (e2e)', () => {
|
||||
expect(mention).toBeTruthy();
|
||||
});
|
||||
|
||||
it('updates own comment content and mention usernames', async () => {
|
||||
const createResponse = await request(app.getHttpServer())
|
||||
.post('/api/v1/comments')
|
||||
.set('Authorization', `Bearer ${secondary.accessToken}`)
|
||||
.send({
|
||||
postId,
|
||||
content: 'Needs edit',
|
||||
})
|
||||
.expect(201);
|
||||
|
||||
editableCommentId = createResponse.body._id || createResponse.body.id;
|
||||
|
||||
const updatedContent = `Edited with @${primary.username}`;
|
||||
const updateResponse = await request(app.getHttpServer())
|
||||
.patch(`/api/v1/comments/${editableCommentId}`)
|
||||
.set('Authorization', `Bearer ${secondary.accessToken}`)
|
||||
.send({
|
||||
content: updatedContent,
|
||||
mentionUsernames: [primary.username],
|
||||
})
|
||||
.expect(200);
|
||||
|
||||
expect(updateResponse.body.content).toBe(updatedContent);
|
||||
expect(updateResponse.body.mentionUsernames).toContain(primary.username.toLowerCase());
|
||||
|
||||
const commentsResponse = await request(app.getHttpServer())
|
||||
.get(`/api/v1/comments/post/${postId}?page=1&limit=20`)
|
||||
.set('Authorization', `Bearer ${primary.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const updatedComment = (commentsResponse.body.items ?? []).find(
|
||||
(item: any) => (item._id || item.id) === editableCommentId,
|
||||
);
|
||||
|
||||
expect(updatedComment?.content).toBe(updatedContent);
|
||||
|
||||
const notificationsResponse = await request(app.getHttpServer())
|
||||
.get('/api/v1/notifications?resourceType=comment')
|
||||
.set('Authorization', `Bearer ${primary.accessToken}`)
|
||||
.expect(200);
|
||||
|
||||
const mention = (notificationsResponse.body.items ?? []).find(
|
||||
(item: any) =>
|
||||
item.type === 'mention' &&
|
||||
item.resourceType === 'comment' &&
|
||||
item.previewText === updatedContent,
|
||||
);
|
||||
|
||||
expect(mention).toBeTruthy();
|
||||
});
|
||||
|
||||
it('supports superadmin sessions refresh rotation and notifications access', async () => {
|
||||
const loginOne = await request(app.getHttpServer())
|
||||
.post('/api/v1/auth/superadmin/login')
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم