Support comment updates and multipart post uploads

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

عرض الملف

@@ -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": [

عرض الملف

@@ -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 },

عرض الملف

@@ -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')