From 3d2a41b22ac0df1b1c307ab653ec78f78430f9ff Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Mon, 15 Jun 2026 15:05:30 +0300 Subject: [PATCH] Add chat hiding mentions follow counts waveform and post fixes --- ...a-Auth-Users-Posts.postman_collection.json | 206 +++++++++++++++++- .../Oudelaa-Mobile.postman_collection.json | 206 +++++++++++++++++- .../utils/post-media-response.util.spec.ts | 4 + src/common/utils/post-media-response.util.ts | 21 +- src/common/utils/waveform.util.spec.ts | 28 +++ src/common/utils/waveform.util.ts | 93 +++++++- src/modules/chat/chat.controller.ts | 10 + src/modules/chat/chat.repository.spec.ts | 57 +++++ src/modules/chat/chat.repository.ts | 68 +++++- src/modules/chat/chat.service.spec.ts | 27 +++ src/modules/chat/chat.service.ts | 19 ++ .../chat/schemas/conversation.schema.ts | 17 +- .../collaboration-requests.service.spec.ts | 48 ++++ .../collaboration-requests.service.ts | 33 ++- src/modules/media/media.service.spec.ts | 5 + src/modules/media/media.service.ts | 9 +- src/modules/posts/dto/create-post.dto.ts | 8 + src/modules/posts/dto/post-query.dto.ts | 10 +- src/modules/posts/dto/update-post.dto.ts | 8 + src/modules/posts/posts.controller.ts | 2 + src/modules/posts/posts.repository.ts | 10 +- src/modules/posts/posts.service.spec.ts | 122 +++++++++++ src/modules/posts/posts.service.ts | 193 ++++++++++++---- src/modules/posts/schemas/post.schema.ts | 50 ++++- src/modules/users/users.controller.spec.ts | 22 ++ src/modules/users/users.controller.ts | 7 + src/modules/users/users.service.spec.ts | 71 +++++- src/modules/users/users.service.ts | 73 +++++++ 28 files changed, 1352 insertions(+), 75 deletions(-) create mode 100644 src/modules/posts/posts.service.spec.ts diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index 674328e..4680a0b 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -943,6 +943,41 @@ } } ] + }, + { + "name": "Search Users For Mention", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/search/users?q={{mentionQuery}}&page=1&limit=10" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items || []).to.be.an('array');", + "if ((json.items || []).length) {", + " const user = json.items[0];", + " pm.expect(user).to.have.property('_id');", + " pm.expect(user).to.have.property('username');", + " pm.expect(user).to.have.property('name');", + " pm.expect(user).to.have.property('stageName');", + " pm.expect(user).to.have.property('avatar');", + " pm.expect(user).to.have.property('isVerified');", + "}" + ] + } + } + ] } ] }, @@ -1730,6 +1765,41 @@ ] } } + }, + { + "name": "Get User Follow Counts", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/{{targetUserId}}/follow-counts" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.userId).to.be.a('string');", + "pm.expect(json.followersCount).to.be.a('number');", + "pm.expect(json.followingCount).to.be.a('number');", + "pm.expect(json.followRequestsCount).to.be.a('number');", + "pm.expect(json.viewerState).to.be.an('object');", + "pm.expect(json.viewerState).to.have.property('isOwnProfile');", + "pm.expect(json.viewerState).to.have.property('isFollowing');", + "pm.expect(json.viewerState).to.have.property('hasPendingFollowRequest');", + "pm.expect(json.viewerState).to.have.property('isBlocked');", + "pm.expect(json.viewerState).to.have.property('isBlockedByUser');" + ] + } + } + ] } ] }, @@ -2053,7 +2123,8 @@ "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", "const json = pm.response.json();", "pm.expect(json._id || json.id).to.exist;", - "pm.expect(json).to.have.property('content');" + "pm.expect(json).to.have.property('content');", + "pm.expect(pm.response.json()).to.have.property('isPinned');" ] } } @@ -2513,6 +2584,11 @@ "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.media.audioUrl).to.be.a('string');", "pm.expect(json.media.waveformPeaks).to.be.an('array');", + "pm.expect(json.media.waveformPeaksPreview).to.be.an('array');", + "pm.expect(json.media.waveformPeaksDetailed).to.be.an('array');", + "pm.expect(json.media.waveformPeaks.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksPreview.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksDetailed.length).to.be.within(100, 1200);", "pm.expect(json.media.durationSeconds).to.exist;", "pm.expect(json.processingStatus).to.eql('ready');", "pm.expect(json.media).to.be.an('object');", @@ -2611,12 +2687,17 @@ "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.media.audioUrl).to.be.a('string');", "pm.expect(json.media.waveformPeaks).to.be.an('array');", + "pm.expect(json.media.waveformPeaksPreview).to.be.an('array');", + "pm.expect(json.media.waveformPeaksDetailed).to.be.an('array');", + "pm.expect(json.media.waveformPeaks.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksPreview.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksDetailed.length).to.be.within(100, 1200);", "pm.expect(json.media.durationSeconds).to.exist;", "pm.expect(json.processingStatus).to.eql('ready');", "pm.expect(json.media).to.be.an('object');", "pm.expect(json.media.mediaType).to.eql(json.postType);", "pm.expect(json.waveformPeaks).to.be.an('array');", - "pm.expect(json.waveformPeaks.length).to.eql(6);", + "pm.expect(json.waveformPeaks.length).to.be.within(80, 120);", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.environment.set('postId', json._id || json.id);", "pm.environment.set('ownPostId', json._id || json.id);" @@ -3120,6 +3201,35 @@ } } }, + { + "name": "Get User Archived Posts", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/user/{{userId}}?visibility=archived&sortBy=createdAt&sortOrder=desc&page=1&limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items || []).to.be.an('array');", + "for (const post of (json.items || [])) {", + " pm.expect(post.isArchived).to.eql(true);", + "}" + ] + } + } + ] + }, { "name": "Create Carousel Post With Metadata", "request": { @@ -3349,6 +3459,69 @@ ] } } + }, + { + "name": "Create Post With Mentions", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "تعاون جميل مع @{{mentionUsername}}", + "type": "text" + }, + { + "key": "mentionUsernames", + "value": "[\"{{mentionUsername}}\"]", + "type": "text" + }, + { + "key": "mentionedUserIds", + "value": "[\"{{targetUserId}}\"]", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 201 or 200\", function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });", + "const json = pm.response.json();", + "pm.expect(json.mentionUsernames || []).to.be.an('array');", + "pm.expect(json.mentionedUsers || []).to.be.an('array');", + "if ((json.mentionedUsers || []).length) {", + " const mentioned = json.mentionedUsers[0];", + " pm.expect(mentioned).to.have.property('_id');", + " pm.expect(mentioned).to.have.property('id');", + " pm.expect(mentioned).to.have.property('username');", + " pm.expect(mentioned).to.have.property('name');", + " pm.expect(mentioned).to.have.property('stageName');", + " pm.expect(mentioned).to.have.property('avatar');", + " pm.expect(mentioned).to.have.property('isVerified');", + "}", + "pm.environment.set(\"postId\", json._id || json.id);" + ] + } + } + ] } ] }, @@ -5340,6 +5513,35 @@ } ] }, + { + "name": "Hide Conversation For Me", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/conversations/{{conversationId}}", + "description": "Hides the conversation from the current user only. Messages and the other participant conversation list are not deleted. A newer message makes it visible again." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);", + "pm.expect(json.conversationId).to.eql(pm.environment.get(\"conversationId\"));", + "pm.expect(json.deletedForMe).to.eql(true);" + ] + } + } + ] + }, { "name": "Send Message", "request": { diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index 8a831f9..aa5670e 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -794,6 +794,41 @@ } } ] + }, + { + "name": "Search Users For Mention", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/search/users?q={{mentionQuery}}&page=1&limit=10" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items || []).to.be.an('array');", + "if ((json.items || []).length) {", + " const user = json.items[0];", + " pm.expect(user).to.have.property('_id');", + " pm.expect(user).to.have.property('username');", + " pm.expect(user).to.have.property('name');", + " pm.expect(user).to.have.property('stageName');", + " pm.expect(user).to.have.property('avatar');", + " pm.expect(user).to.have.property('isVerified');", + "}" + ] + } + } + ] } ] }, @@ -1239,6 +1274,41 @@ ] } } + }, + { + "name": "Get User Follow Counts", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/users/{{targetUserId}}/follow-counts" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.userId).to.be.a('string');", + "pm.expect(json.followersCount).to.be.a('number');", + "pm.expect(json.followingCount).to.be.a('number');", + "pm.expect(json.followRequestsCount).to.be.a('number');", + "pm.expect(json.viewerState).to.be.an('object');", + "pm.expect(json.viewerState).to.have.property('isOwnProfile');", + "pm.expect(json.viewerState).to.have.property('isFollowing');", + "pm.expect(json.viewerState).to.have.property('hasPendingFollowRequest');", + "pm.expect(json.viewerState).to.have.property('isBlocked');", + "pm.expect(json.viewerState).to.have.property('isBlockedByUser');" + ] + } + } + ] } ] }, @@ -1562,7 +1632,8 @@ "pm.test('Status is 200', function () { pm.response.to.have.status(200); });", "const json = pm.response.json();", "pm.expect(json._id || json.id).to.exist;", - "pm.expect(json).to.have.property('content');" + "pm.expect(json).to.have.property('content');", + "pm.expect(pm.response.json()).to.have.property('isPinned');" ] } } @@ -2022,6 +2093,11 @@ "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.media.audioUrl).to.be.a('string');", "pm.expect(json.media.waveformPeaks).to.be.an('array');", + "pm.expect(json.media.waveformPeaksPreview).to.be.an('array');", + "pm.expect(json.media.waveformPeaksDetailed).to.be.an('array');", + "pm.expect(json.media.waveformPeaks.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksPreview.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksDetailed.length).to.be.within(100, 1200);", "pm.expect(json.media.durationSeconds).to.exist;", "pm.expect(json.processingStatus).to.eql('ready');", "pm.expect(json.media).to.be.an('object');", @@ -2120,12 +2196,17 @@ "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.media.audioUrl).to.be.a('string');", "pm.expect(json.media.waveformPeaks).to.be.an('array');", + "pm.expect(json.media.waveformPeaksPreview).to.be.an('array');", + "pm.expect(json.media.waveformPeaksDetailed).to.be.an('array');", + "pm.expect(json.media.waveformPeaks.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksPreview.length).to.be.within(80, 120);", + "pm.expect(json.media.waveformPeaksDetailed.length).to.be.within(100, 1200);", "pm.expect(json.media.durationSeconds).to.exist;", "pm.expect(json.processingStatus).to.eql('ready');", "pm.expect(json.media).to.be.an('object');", "pm.expect(json.media.mediaType).to.eql(json.postType);", "pm.expect(json.waveformPeaks).to.be.an('array');", - "pm.expect(json.waveformPeaks.length).to.eql(6);", + "pm.expect(json.waveformPeaks.length).to.be.within(80, 120);", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.environment.set('postId', json._id || json.id);", "pm.environment.set('ownPostId', json._id || json.id);" @@ -2629,6 +2710,35 @@ } } }, + { + "name": "Get User Archived Posts", + "request": { + "method": "GET", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts/user/{{userId}}?visibility=archived&sortBy=createdAt&sortOrder=desc&page=1&limit=20" + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.items || []).to.be.an('array');", + "for (const post of (json.items || [])) {", + " pm.expect(post.isArchived).to.eql(true);", + "}" + ] + } + } + ] + }, { "name": "Create Carousel Post With Metadata", "request": { @@ -2821,6 +2931,69 @@ } } } + }, + { + "name": "Create Post With Mentions", + "request": { + "method": "POST", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/posts", + "body": { + "mode": "formdata", + "formdata": [ + { + "key": "content", + "value": "تعاون جميل مع @{{mentionUsername}}", + "type": "text" + }, + { + "key": "mentionUsernames", + "value": "[\"{{mentionUsername}}\"]", + "type": "text" + }, + { + "key": "mentionedUserIds", + "value": "[\"{{targetUserId}}\"]", + "type": "text" + }, + { + "key": "visibility", + "value": "public", + "type": "text" + } + ] + } + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 201 or 200\", function () { pm.expect(pm.response.code).to.be.oneOf([200, 201]); });", + "const json = pm.response.json();", + "pm.expect(json.mentionUsernames || []).to.be.an('array');", + "pm.expect(json.mentionedUsers || []).to.be.an('array');", + "if ((json.mentionedUsers || []).length) {", + " const mentioned = json.mentionedUsers[0];", + " pm.expect(mentioned).to.have.property('_id');", + " pm.expect(mentioned).to.have.property('id');", + " pm.expect(mentioned).to.have.property('username');", + " pm.expect(mentioned).to.have.property('name');", + " pm.expect(mentioned).to.have.property('stageName');", + " pm.expect(mentioned).to.have.property('avatar');", + " pm.expect(mentioned).to.have.property('isVerified');", + "}", + "pm.environment.set(\"postId\", json._id || json.id);" + ] + } + } + ] } ] }, @@ -4892,6 +5065,35 @@ } ] }, + { + "name": "Hide Conversation For Me", + "request": { + "method": "DELETE", + "header": [ + { + "key": "Authorization", + "value": "Bearer {{accessToken}}" + } + ], + "url": "{{baseUrl}}/chat/conversations/{{conversationId}}", + "description": "Hides the conversation from the current user only. Messages and the other participant conversation list are not deleted. A newer message makes it visible again." + }, + "event": [ + { + "listen": "test", + "script": { + "type": "text/javascript", + "exec": [ + "pm.test(\"Status is 200\", function () { pm.response.to.have.status(200); });", + "const json = pm.response.json();", + "pm.expect(json.success).to.eql(true);", + "pm.expect(json.conversationId).to.eql(pm.environment.get(\"conversationId\"));", + "pm.expect(json.deletedForMe).to.eql(true);" + ] + } + } + ] + }, { "name": "Send Message", "request": { diff --git a/src/common/utils/post-media-response.util.spec.ts b/src/common/utils/post-media-response.util.spec.ts index a49eedb..e3a9258 100644 --- a/src/common/utils/post-media-response.util.spec.ts +++ b/src/common/utils/post-media-response.util.spec.ts @@ -13,6 +13,8 @@ describe('post media response util', () => { expect(media.mediaType).toBe(PostType.AUDIO); expect(media.durationSeconds).toBe(42); expect(media.waveformPeaks).toHaveLength(100); + expect(media.waveformPeaksPreview).toHaveLength(100); + expect(media.waveformPeaksDetailed.length).toBeGreaterThanOrEqual(100); expect(media.waveformPeaks.every((value) => Number.isInteger(value))).toBe(true); expect(media.waveformPeaks.every((value) => value >= 0 && value <= 100)).toBe(true); }); @@ -26,5 +28,7 @@ describe('post media response util', () => { expect(media.mediaType).toBe(PostType.IMAGE); expect(media.waveformPeaks).toEqual([]); + expect(media.waveformPeaksPreview).toEqual([]); + expect(media.waveformPeaksDetailed).toEqual([]); }); }); diff --git a/src/common/utils/post-media-response.util.ts b/src/common/utils/post-media-response.util.ts index 0db9d31..945679b 100644 --- a/src/common/utils/post-media-response.util.ts +++ b/src/common/utils/post-media-response.util.ts @@ -1,5 +1,5 @@ import { PostType } from '../enums/post-type.enum'; -import { normalizeWaveformPeaks } from './waveform.util'; +import { buildWaveformPeakSet } from './waveform.util'; type VariantSet = { originalUrl?: string; @@ -19,6 +19,8 @@ type PostMediaInput = { thumbnailUrl?: string; thumbnailVariants?: VariantSet; waveformPeaks?: number[]; + waveformPeaksPreview?: number[]; + waveformPeaksDetailed?: number[]; durationSeconds?: number | null; }; @@ -88,8 +90,17 @@ export const buildPostMediaResponse = (post: PostMediaInput) => { : mediaType === PostType.VIDEO || mediaType === PostType.AUDIO ? thumbnailUrl : ''; - const waveformPeaks = - mediaType === PostType.AUDIO ? normalizeWaveformPeaks(post.waveformPeaks ?? []) : []; + const waveformSet = + mediaType === PostType.AUDIO + ? buildWaveformPeakSet( + post.waveformPeaksDetailed?.length + ? post.waveformPeaksDetailed + : post.waveformPeaksPreview?.length + ? post.waveformPeaksPreview + : post.waveformPeaks, + post.durationSeconds, + ) + : { waveformPeaks: [], waveformPeaksPreview: [], waveformPeaksDetailed: [] }; return { mediaType, @@ -102,7 +113,9 @@ export const buildPostMediaResponse = (post: PostMediaInput) => { audioUrl: post.audioUrl ?? '', images, durationSeconds: post.durationSeconds ?? null, - waveformPeaks, + waveformPeaks: waveformSet.waveformPeaks, + waveformPeaksPreview: waveformSet.waveformPeaksPreview, + waveformPeaksDetailed: waveformSet.waveformPeaksDetailed, isPlayable: mediaType === PostType.VIDEO ? !!preferredPlaybackUrl : mediaType === PostType.AUDIO ? !!post.audioUrl : false, }; }; diff --git a/src/common/utils/waveform.util.spec.ts b/src/common/utils/waveform.util.spec.ts index ec04329..af7aff7 100644 --- a/src/common/utils/waveform.util.spec.ts +++ b/src/common/utils/waveform.util.spec.ts @@ -1,6 +1,9 @@ import { + buildWaveformPeakSet, + generateWaveformPeakSetFromBuffer, generateWaveformPeaksFromBuffer, generateWaveformPeaksFromSeed, + getDetailedWaveformPeakCount, normalizeWaveformPeaks, } from './waveform.util'; @@ -109,4 +112,29 @@ describe('waveform util', () => { expect(peaks.slice(0, 40).some((value) => value < 60)).toBe(true); expect(peaks.slice(60).some((value) => value > 80)).toBe(true); }); + + it('generates detailed peaks for long audio without changing preview size', () => { + const detailedCount = getDetailedWaveformPeakCount(180); + const waveformSet = buildWaveformPeakSet( + Array.from({ length: 1400 }, (_, index) => (index % 17) + 1), + 180, + ); + + expect(detailedCount).toBeGreaterThanOrEqual(600); + expectDisplaySafeWaveform(waveformSet.waveformPeaksPreview, 100); + expectDisplaySafeWaveform(waveformSet.waveformPeaks, 100); + expect(waveformSet.waveformPeaksDetailed.length).toBeGreaterThanOrEqual(600); + expect(waveformSet.waveformPeaksDetailed.length).toBeLessThanOrEqual(1200); + expect(waveformSet.waveformPeaksDetailed.every((value) => value >= 0 && value <= 100)).toBe(true); + }); + + it('generates preview and detailed peak sets from buffers', () => { + const wavBuffer = createPcmWavBuffer((frame) => 0.15 + (frame / 7999) * 0.7); + const waveformSet = generateWaveformPeakSetFromBuffer(wavBuffer, 240); + + expectDisplaySafeWaveform(waveformSet.waveformPeaksPreview, 100); + expect(waveformSet.waveformPeaksDetailed.length).toBeGreaterThanOrEqual(600); + expect(waveformSet.waveformPeaksDetailed.length).toBeLessThanOrEqual(1200); + expect(new Set(waveformSet.waveformPeaksDetailed).size).toBeGreaterThan(8); + }); }); diff --git a/src/common/utils/waveform.util.ts b/src/common/utils/waveform.util.ts index e48000a..b596af5 100644 --- a/src/common/utils/waveform.util.ts +++ b/src/common/utils/waveform.util.ts @@ -1,6 +1,16 @@ export const DEFAULT_WAVEFORM_PEAK_COUNT = 100; +export const DEFAULT_DETAILED_WAVEFORM_PEAK_COUNT = 800; const MIN_DISPLAY_WAVEFORM_PEAKS = 80; const MAX_DISPLAY_WAVEFORM_PEAKS = 120; +const MIN_DETAILED_WAVEFORM_PEAKS = 100; +const LONG_AUDIO_MIN_DETAILED_WAVEFORM_PEAKS = 600; +const MAX_DETAILED_WAVEFORM_PEAKS = 1200; + +export type WaveformPeakSet = { + waveformPeaks: number[]; + waveformPeaksPreview: number[]; + waveformPeaksDetailed: number[]; +}; export const getDefaultWaveformPeakCount = (): number => { const configured = Number(process.env.AUDIO_WAVEFORM_PEAKS); @@ -17,6 +27,19 @@ export const getDefaultWaveformPeakCount = (): number => { const clampPeak = (value: number): number => Math.max(0, Math.min(100, Math.round(value))); +const clampSampleCount = ( + targetCount: number, + fallback: number, + min: number, + max: number, +): number => { + if (!Number.isInteger(targetCount) || targetCount < min || targetCount > max) { + return fallback; + } + + return targetCount; +}; + const fallbackWaveform = (samples: number): number[] => Array.from({ length: samples }, (_, index) => { const wave = Math.sin((index / Math.max(1, samples - 1)) * Math.PI * 6); @@ -154,12 +177,12 @@ export const normalizeWaveformPeaks = ( input: unknown[] | undefined, targetCount = getDefaultWaveformPeakCount(), ): number[] => { - const samples = - Number.isInteger(targetCount) && - targetCount >= MIN_DISPLAY_WAVEFORM_PEAKS && - targetCount <= MAX_DISPLAY_WAVEFORM_PEAKS - ? targetCount - : DEFAULT_WAVEFORM_PEAK_COUNT; + const samples = clampSampleCount( + targetCount, + DEFAULT_WAVEFORM_PEAK_COUNT, + MIN_DISPLAY_WAVEFORM_PEAKS, + MAX_DETAILED_WAVEFORM_PEAKS, + ); if (!input?.length) { return fallbackWaveform(samples); @@ -255,3 +278,61 @@ export const generateWaveformPeaksFromSeed = ( return normalizeWaveformPeaks(peaks, samples); }; + +export const getDetailedWaveformPeakCount = (durationSeconds?: number | null): number => { + if (!Number.isFinite(durationSeconds) || !durationSeconds || durationSeconds <= 0) { + return DEFAULT_DETAILED_WAVEFORM_PEAK_COUNT; + } + + const byQuarterSecond = Math.ceil(durationSeconds / 0.25); + const minimum = durationSeconds >= 120 ? LONG_AUDIO_MIN_DETAILED_WAVEFORM_PEAKS : MIN_DETAILED_WAVEFORM_PEAKS; + return Math.max(minimum, Math.min(MAX_DETAILED_WAVEFORM_PEAKS, byQuarterSecond)); +}; + +export const buildWaveformPeakSet = ( + input: unknown[] | undefined, + durationSeconds?: number | null, +): WaveformPeakSet => { + const preview = normalizeWaveformPeaks(input, getDefaultWaveformPeakCount()); + const detailed = normalizeWaveformPeaks( + input?.length ? input : preview, + getDetailedWaveformPeakCount(durationSeconds), + ); + + return { + waveformPeaks: preview, + waveformPeaksPreview: preview, + waveformPeaksDetailed: detailed, + }; +}; + +export const generateWaveformPeakSetFromBuffer = ( + buffer: Buffer, + durationSeconds?: number | null, +): WaveformPeakSet => { + const detailed = generateWaveformPeaksFromBuffer( + buffer, + getDetailedWaveformPeakCount(durationSeconds), + ); + const preview = normalizeWaveformPeaks(detailed, getDefaultWaveformPeakCount()); + + return { + waveformPeaks: preview, + waveformPeaksPreview: preview, + waveformPeaksDetailed: detailed, + }; +}; + +export const generateWaveformPeakSetFromSeed = ( + seed: string, + durationSeconds?: number | null, +): WaveformPeakSet => { + const detailed = generateWaveformPeaksFromSeed(seed, getDetailedWaveformPeakCount(durationSeconds)); + const preview = normalizeWaveformPeaks(detailed, getDefaultWaveformPeakCount()); + + return { + waveformPeaks: preview, + waveformPeaksPreview: preview, + waveformPeaksDetailed: detailed, + }; +}; diff --git a/src/modules/chat/chat.controller.ts b/src/modules/chat/chat.controller.ts index 249b7c8..ace5742 100644 --- a/src/modules/chat/chat.controller.ts +++ b/src/modules/chat/chat.controller.ts @@ -2,6 +2,7 @@ import { BadRequestException, Body, Controller, + Delete, Get, Param, Patch, @@ -55,6 +56,15 @@ export class ChatController { return this.chatService.getMessages(user.sub, conversationId, query); } + @Delete('conversations/:conversationId') + @Throttle(80, 60_000) + async hideConversationForMe( + @CurrentUser() user: JwtPayload, + @Param('conversationId') conversationId: string, + ) { + return this.chatService.hideConversationForMe(user.sub, conversationId); + } + @Post('messages') @Throttle(120, 60_000) async sendMessage(@CurrentUser() user: JwtPayload, @Body() dto: SendMessageDto) { diff --git a/src/modules/chat/chat.repository.spec.ts b/src/modules/chat/chat.repository.spec.ts index 5587a2a..fe37298 100644 --- a/src/modules/chat/chat.repository.spec.ts +++ b/src/modules/chat/chat.repository.spec.ts @@ -15,6 +15,9 @@ describe('ChatRepository unread counters', () => { create: jest.fn(), findById: jest.fn(), findByIdAndUpdate: jest.fn(), + findOneAndUpdate: jest.fn(), + find: jest.fn(), + countDocuments: jest.fn(), updateOne: jest.fn(), }; messageModel = { @@ -93,6 +96,60 @@ describe('ChatRepository unread counters', () => { ); }); + it('hides a conversation for one user without deleting it', async () => { + const conversationId = new Types.ObjectId().toString(); + const userId = new Types.ObjectId().toString(); + const hiddenAt = new Date('2026-06-15T10:00:00.000Z'); + const updatedConversation = { id: conversationId }; + conversationModel.updateOne.mockReturnValue(queryResult({ modifiedCount: 1 })); + conversationModel.findOneAndUpdate.mockReturnValue(queryResult(updatedConversation)); + + const result = await repository.hideConversationForUser(conversationId, userId, hiddenAt); + + expect(result).toBe(updatedConversation); + expect(conversationModel.updateOne).toHaveBeenCalledWith( + { _id: new Types.ObjectId(conversationId), participantIds: new Types.ObjectId(userId) }, + { $pull: { hiddenFor: { userId: new Types.ObjectId(userId) } } }, + ); + expect(conversationModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: new Types.ObjectId(conversationId), participantIds: new Types.ObjectId(userId) }, + { + $push: { + hiddenFor: { + userId: new Types.ObjectId(userId), + hiddenAt, + }, + }, + }, + { new: true }, + ); + expect(messageModel.updateOne).not.toHaveBeenCalled(); + }); + + it('filters hidden conversations until a newer message arrives', async () => { + const userId = new Types.ObjectId().toString(); + const findChain = { + populate: jest.fn().mockReturnThis(), + sort: jest.fn().mockReturnThis(), + skip: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + exec: jest.fn().mockResolvedValue([]), + }; + conversationModel.find.mockReturnValue(findChain); + + await repository.findConversationsForUser(userId, 0, 20); + + const filter = conversationModel.find.mock.calls[0][0]; + expect(filter.participantIds).toEqual(new Types.ObjectId(userId)); + expect(filter.$expr.$let.vars.hiddenEntry.$first.$filter.cond).toEqual({ + $eq: ['$$hidden.userId', new Types.ObjectId(userId)], + }); + expect(filter.$expr.$let.in.$or[1].$gt).toEqual([ + { $ifNull: ['$lastMessageAt', new Date(0)] }, + '$$hiddenEntry.hiddenAt', + ]); + }); + it('adds a delivered receipt only when the user has not already delivered the message', async () => { const messageId = new Types.ObjectId().toString(); const userId = new Types.ObjectId().toString(); diff --git a/src/modules/chat/chat.repository.ts b/src/modules/chat/chat.repository.ts index af9147d..7fda8c0 100644 --- a/src/modules/chat/chat.repository.ts +++ b/src/modules/chat/chat.repository.ts @@ -52,6 +52,39 @@ export class ChatRepository { }); } + private visibleConversationFilterForUser(userId: string): FilterQuery { + const userObjectId = new Types.ObjectId(userId); + return { + participantIds: userObjectId, + $expr: { + $let: { + vars: { + hiddenEntry: { + $first: { + $filter: { + input: { $ifNull: ['$hiddenFor', []] }, + as: 'hidden', + cond: { $eq: ['$$hidden.userId', userObjectId] }, + }, + }, + }, + }, + in: { + $or: [ + { $eq: ['$$hiddenEntry', null] }, + { + $gt: [ + { $ifNull: ['$lastMessageAt', new Date(0)] }, + '$$hiddenEntry.hiddenAt', + ], + }, + ], + }, + }, + }, + }; + } + async findConversationsForUser( userId: string, skip: number, @@ -59,7 +92,7 @@ export class ChatRepository { sort: Record = { lastMessageAt: -1, updatedAt: -1 }, ): Promise { return this.conversationModel - .find({ participantIds: new Types.ObjectId(userId) }) + .find(this.visibleConversationFilterForUser(userId)) .populate({ path: 'participantIds', select: 'name username stageName avatar isVerified isDisabled' }) .sort(sort) .skip(skip) @@ -68,7 +101,38 @@ export class ChatRepository { } async countConversationsForUser(userId: string): Promise { - return this.conversationModel.countDocuments({ participantIds: new Types.ObjectId(userId) }).exec(); + return this.conversationModel.countDocuments(this.visibleConversationFilterForUser(userId)).exec(); + } + + async hideConversationForUser( + conversationId: string, + userId: string, + hiddenAt: Date, + ): Promise { + const conversationObjectId = new Types.ObjectId(conversationId); + const userObjectId = new Types.ObjectId(userId); + + await this.conversationModel + .updateOne( + { _id: conversationObjectId, participantIds: userObjectId }, + { $pull: { hiddenFor: { userId: userObjectId } } }, + ) + .exec(); + + return this.conversationModel + .findOneAndUpdate( + { _id: conversationObjectId, participantIds: userObjectId }, + { + $push: { + hiddenFor: { + userId: userObjectId, + hiddenAt, + }, + }, + }, + { new: true }, + ) + .exec(); } async createMessage(payload: { diff --git a/src/modules/chat/chat.service.spec.ts b/src/modules/chat/chat.service.spec.ts index 37459ed..68e7220 100644 --- a/src/modules/chat/chat.service.spec.ts +++ b/src/modules/chat/chat.service.spec.ts @@ -27,6 +27,7 @@ describe('ChatService realtime message broadcasting', () => { markMessageSeen: jest.fn().mockResolvedValue(undefined), clearConversationUnreadForUser: jest.fn().mockResolvedValue(undefined), markMessageDelivered: jest.fn().mockResolvedValue(undefined), + hideConversationForUser: jest.fn().mockResolvedValue(conversation), }; notificationsService = { createMessageNotification: jest.fn().mockResolvedValue(null), @@ -163,4 +164,30 @@ describe('ChatService realtime message broadcasting', () => { }); expect(result.seenAt).toEqual(expect.any(String)); }); + + it('hides a conversation for the current user only', async () => { + const result = await service.hideConversationForMe(senderId, conversationId); + + expect(chatRepository.findConversationById).toHaveBeenCalledWith(conversationId); + expect(chatRepository.hideConversationForUser).toHaveBeenCalledWith( + conversationId, + senderId, + expect.any(Date), + ); + expect(chatRepository.createMessage).not.toHaveBeenCalled(); + expect(result).toEqual({ + success: true, + conversationId, + deletedForMe: true, + }); + }); + + it('does not hide a conversation for a non-participant', async () => { + const outsiderId = new Types.ObjectId().toString(); + + await expect(service.hideConversationForMe(outsiderId, conversationId)).rejects.toThrow( + 'You are not a member of this conversation', + ); + expect(chatRepository.hideConversationForUser).not.toHaveBeenCalled(); + }); }); diff --git a/src/modules/chat/chat.service.ts b/src/modules/chat/chat.service.ts index 8aa794e..e60f266 100644 --- a/src/modules/chat/chat.service.ts +++ b/src/modules/chat/chat.service.ts @@ -139,6 +139,25 @@ export class ChatService { }); } + async hideConversationForMe(currentUserId: string, conversationId: string) { + const conversation = await this.assertConversationMember(currentUserId, conversationId); + const hiddenAt = new Date(); + const updated = await this.chatRepository.hideConversationForUser( + conversation.id, + currentUserId, + hiddenAt, + ); + if (!updated) { + throw new NotFoundException('Conversation not found'); + } + + return { + success: true, + conversationId: conversation.id, + deletedForMe: true, + }; + } + async sendMessage(currentUserId: string, dto: SendMessageDto) { const conversation = await this.assertConversationMember(currentUserId, dto.conversationId); await this.assertNoChatBlockInConversation(currentUserId, conversation.participantIds.map((id) => id.toString())); diff --git a/src/modules/chat/schemas/conversation.schema.ts b/src/modules/chat/schemas/conversation.schema.ts index 98bf53a..67606b3 100644 --- a/src/modules/chat/schemas/conversation.schema.ts +++ b/src/modules/chat/schemas/conversation.schema.ts @@ -1,9 +1,14 @@ -import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; +import { Prop, Schema, SchemaFactory, raw } from '@nestjs/mongoose'; import { HydratedDocument, Types } from 'mongoose'; import { User } from '../../users/schemas/user.schema'; export type ConversationDocument = HydratedDocument; +const hiddenForSchema = raw({ + userId: { type: Types.ObjectId, ref: User.name, required: true }, + hiddenAt: { type: Date, required: true }, +}); + @Schema({ timestamps: true, versionKey: false }) export class Conversation { @Prop({ type: [Types.ObjectId], ref: User.name, required: true, index: true }) @@ -29,12 +34,16 @@ export class Conversation { @Prop({ type: Map, of: Number, default: {} }) unreadCountByUser!: Map; + + @Prop({ type: [hiddenForSchema], default: [] }) + hiddenFor!: Array<{ userId: Types.ObjectId; hiddenAt: Date }>; } export const ConversationSchema = SchemaFactory.createForClass(Conversation); ConversationSchema.index({ participantIds: 1, updatedAt: -1 }); ConversationSchema.index({ lastMessageAt: -1, updatedAt: -1 }); ConversationSchema.index({ participantIds: 1, isGroup: 1, lastMessageAt: -1 }); +ConversationSchema.index({ 'hiddenFor.userId': 1, lastMessageAt: -1 }); const normalizeUnreadCountByUser = (value: unknown): Record => { if (!value) { @@ -60,6 +69,12 @@ const normalizeUnreadCountByUser = (value: unknown): Record => { const transformConversation = (_doc: unknown, ret: any) => { ret.unreadCountByUser = normalizeUnreadCountByUser(ret.unreadCountByUser); + ret.hiddenFor = Array.isArray(ret.hiddenFor) + ? ret.hiddenFor.map((item: any) => ({ + userId: item.userId?.toString?.() ?? item.userId, + hiddenAt: item.hiddenAt ?? null, + })) + : []; return ret; }; diff --git a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts index b591d9e..6664c7a 100644 --- a/src/modules/collaboration-requests/collaboration-requests.service.spec.ts +++ b/src/modules/collaboration-requests/collaboration-requests.service.spec.ts @@ -120,6 +120,54 @@ describe('CollaborationRequestsService', () => { ); }); + it('uses populated post author id as target user and ignores empty attachmentUrl strings', async () => { + const requesterId = new Types.ObjectId().toString(); + const targetUserId = new Types.ObjectId().toString(); + const postId = new Types.ObjectId().toString(); + const request = createRequestDoc({ + postId: new Types.ObjectId(postId), + requesterId: new Types.ObjectId(requesterId), + targetUserId: new Types.ObjectId(targetUserId), + attachmentUrl: '', + attachmentType: 'audio', + }); + const { service, model, postsRepository, usersRepository, blocksRepository } = createService(); + + postsRepository.findById.mockResolvedValue({ + authorId: { _id: new Types.ObjectId(targetUserId), username: 'owner' }, + }); + usersRepository.findById.mockResolvedValue({ isDisabled: false }); + blocksRepository.findAnyBetween.mockResolvedValue(null); + model.findOne + .mockReturnValueOnce(chain(null)) + .mockReturnValueOnce(chain({ ...request, populated: true })); + model.findOneAndUpdate.mockReturnValue(chain(request)); + + await expect( + service.create(requesterId, postId, { + collaborationType: 'duet', + message: 'Let us work', + attachmentType: 'audio', + attachmentUrl: '', + }), + ).resolves.toMatchObject({ + message: 'Collaboration request sent', + }); + + expect(usersRepository.findById).toHaveBeenCalledWith(targetUserId); + expect(model.findOneAndUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + targetUserId: new Types.ObjectId(targetUserId), + }), + expect.objectContaining({ + $setOnInsert: expect.not.objectContaining({ + attachmentUrl: '', + }), + }), + expect.any(Object), + ); + }); + it('prevents the post owner from requesting collaboration on their own post', async () => { const requesterId = new Types.ObjectId().toString(); const postId = new Types.ObjectId().toString(); diff --git a/src/modules/collaboration-requests/collaboration-requests.service.ts b/src/modules/collaboration-requests/collaboration-requests.service.ts index cfe1283..ac670c7 100644 --- a/src/modules/collaboration-requests/collaboration-requests.service.ts +++ b/src/modules/collaboration-requests/collaboration-requests.service.ts @@ -62,7 +62,10 @@ export class CollaborationRequestsService { throw new NotFoundException('Post not found'); } - const targetUserId = post.authorId.toString(); + const targetUserId = this.extractEntityId(post.authorId); + if (!Types.ObjectId.isValid(targetUserId)) { + throw new BadRequestException('Post owner is invalid'); + } if (requesterId === targetUserId) { throw new BadRequestException('You cannot request collaboration on your own post'); @@ -345,10 +348,11 @@ export class CollaborationRequestsService { private buildCollaborationDetails( dto: CreateCollaborationRequestDto | CreateGeneralCollaborationRequestDto, ) { + const attachmentUrl = typeof dto.attachmentUrl === 'string' ? dto.attachmentUrl.trim() : ''; return { ...(dto.collaborationType ? { collaborationType: dto.collaborationType } : {}), ...(typeof dto.message === 'string' ? { message: dto.message.trim() } : {}), - ...(typeof dto.attachmentUrl === 'string' ? { attachmentUrl: dto.attachmentUrl.trim() } : {}), + ...(attachmentUrl ? { attachmentUrl } : {}), ...(dto.attachmentType ? { attachmentType: dto.attachmentType } : {}), }; } @@ -565,4 +569,29 @@ export class CollaborationRequestsService { deepLink: `/users/${actorId}`, }; } + + private extractEntityId(value: unknown): string { + if (!value) { + return ''; + } + if (typeof value === 'string') { + return value; + } + if (value instanceof Types.ObjectId) { + return value.toString(); + } + if (typeof value === 'object') { + const candidate = value as { _id?: unknown; id?: unknown }; + if (candidate._id instanceof Types.ObjectId) { + return candidate._id.toString(); + } + if (typeof candidate._id === 'string') { + return candidate._id; + } + if (typeof candidate.id === 'string') { + return candidate.id; + } + } + return ''; + } } diff --git a/src/modules/media/media.service.spec.ts b/src/modules/media/media.service.spec.ts index 152c044..1110b66 100644 --- a/src/modules/media/media.service.spec.ts +++ b/src/modules/media/media.service.spec.ts @@ -143,6 +143,11 @@ describe('MediaService', () => { expect(result.mimeType).toBe('audio/wav'); expect(result.sizeBytes).toBe(audioBuffer.length); expect(result.waveformPeaks).toHaveLength(100); + expect(result.waveformPeaksPreview).toHaveLength(100); + expect(result.waveformPeaksDetailed.length).toBeGreaterThanOrEqual(100); + expect(result.waveformPeaksDetailed.length).toBeLessThanOrEqual(1200); + expect(result.waveformPeaksDetailed.every((value: number) => Number.isInteger(value))).toBe(true); + expect(result.waveformPeaksDetailed.every((value: number) => value >= 0 && value <= 100)).toBe(true); expect(storageService.resolveLocalFilePath).toHaveBeenCalledWith( '/uploads/ai-music/generated.wav', ); diff --git a/src/modules/media/media.service.ts b/src/modules/media/media.service.ts index 729c683..b7b7650 100644 --- a/src/modules/media/media.service.ts +++ b/src/modules/media/media.service.ts @@ -2,7 +2,7 @@ import { BadGatewayException, Injectable, ServiceUnavailableException } from '@n import { ConfigService } from '@nestjs/config'; import { readFile } from 'fs/promises'; import { GoogleAuth } from 'google-auth-library'; -import { generateWaveformPeaksFromBuffer } from '../../common/utils/waveform.util'; +import { generateWaveformPeakSetFromBuffer } from '../../common/utils/waveform.util'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { MediaProbeService } from '../../infrastructure/storage/media-probe.service'; import { AiMusicPromptEnhancerService } from './ai-music-prompt-enhancer.service'; @@ -213,6 +213,11 @@ export class MediaService { this.resolveSavedAudioBuffer(savedAudioPath, buffer), ]); + const waveformSet = generateWaveformPeakSetFromBuffer( + waveformBuffer, + actualDurationSeconds ?? dto.durationSeconds ?? 12, + ); + return { prompt: dto.prompt, originalPrompt: promptEnhancement.originalPrompt, @@ -222,7 +227,7 @@ export class MediaService { mimeType, sizeBytes: buffer.length, audioUrl, - waveformPeaks: generateWaveformPeaksFromBuffer(waveformBuffer), + ...waveformSet, }; } diff --git a/src/modules/posts/dto/create-post.dto.ts b/src/modules/posts/dto/create-post.dto.ts index 3e9bcb0..1cb047d 100644 --- a/src/modules/posts/dto/create-post.dto.ts +++ b/src/modules/posts/dto/create-post.dto.ts @@ -126,6 +126,14 @@ export class CreatePostDto { @Length(1, 30, { each: true }) mentionUsernames?: string[]; + @ApiPropertyOptional({ type: [String], description: 'Mentioned user ids (max 30)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(30) + @IsMongoId({ each: true }) + mentionedUserIds?: string[]; + @ApiPropertyOptional({ description: 'Post location text' }) @IsOptional() @IsString() diff --git a/src/modules/posts/dto/post-query.dto.ts b/src/modules/posts/dto/post-query.dto.ts index 0dfbec5..58a89b3 100644 --- a/src/modules/posts/dto/post-query.dto.ts +++ b/src/modules/posts/dto/post-query.dto.ts @@ -1,5 +1,5 @@ import { ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsOptional, IsString } from 'class-validator'; +import { IsEnum, IsIn, IsOptional, IsString } from 'class-validator'; import { PaginationQueryDto } from '../../../common/dto/pagination-query.dto'; import { PostType } from '../../../common/enums/post-type.enum'; import { PostVisibility } from '../../../common/enums/post-visibility.enum'; @@ -16,12 +16,14 @@ export const POST_SORT_FIELDS = [ ] as const; export type PostSortField = (typeof POST_SORT_FIELDS)[number]; +export const POST_VISIBILITY_FILTERS = [...Object.values(PostVisibility), 'archived'] as const; +export type PostVisibilityFilter = (typeof POST_VISIBILITY_FILTERS)[number]; export class PostQueryDto extends PaginationQueryDto { - @ApiPropertyOptional({ enum: PostVisibility }) + @ApiPropertyOptional({ enum: POST_VISIBILITY_FILTERS }) @IsOptional() - @IsEnum(PostVisibility) - visibility?: PostVisibility; + @IsIn(POST_VISIBILITY_FILTERS) + visibility?: PostVisibilityFilter; @ApiPropertyOptional({ enum: PostType }) @IsOptional() diff --git a/src/modules/posts/dto/update-post.dto.ts b/src/modules/posts/dto/update-post.dto.ts index b77e81b..176dece 100644 --- a/src/modules/posts/dto/update-post.dto.ts +++ b/src/modules/posts/dto/update-post.dto.ts @@ -126,6 +126,14 @@ export class UpdatePostDto { @Length(1, 30, { each: true }) mentionUsernames?: string[]; + @ApiPropertyOptional({ type: [String], description: 'Set mentioned user ids (max 30)' }) + @IsOptional() + @Transform(toStringArray) + @IsArray() + @ArrayMaxSize(30) + @IsMongoId({ each: true }) + mentionedUserIds?: string[]; + @ApiPropertyOptional({ description: 'Post location text' }) @IsOptional() @IsString() diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index 98e195d..e849fa0 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -61,6 +61,7 @@ export class PostsController { taggedUserIds: { type: 'array', items: { type: 'string' } }, collaboratorIds: { type: 'array', items: { type: 'string' } }, mentionUsernames: { type: 'array', items: { type: 'string' } }, + mentionedUserIds: { type: 'array', items: { type: 'string' } }, location: { type: 'string', example: 'Riyadh, Saudi Arabia' }, latitude: { type: 'number', example: 24.7136 }, longitude: { type: 'number', example: 46.6753 }, @@ -199,6 +200,7 @@ export class PostsController { taggedUserIds: { type: 'array', items: { type: 'string' } }, collaboratorIds: { type: 'array', items: { type: 'string' } }, mentionUsernames: { type: 'array', items: { type: 'string' } }, + mentionedUserIds: { type: 'array', items: { type: 'string' } }, location: { type: 'string', example: 'Jeddah, Saudi Arabia' }, latitude: { type: 'number', example: 21.5433 }, longitude: { type: 'number', example: 39.1728 }, diff --git a/src/modules/posts/posts.repository.ts b/src/modules/posts/posts.repository.ts index 1b78396..32892b3 100644 --- a/src/modules/posts/posts.repository.ts +++ b/src/modules/posts/posts.repository.ts @@ -12,7 +12,9 @@ export class PostsRepository { return { ...filter, isDeleted: { $ne: true }, - isArchived: { $ne: true }, + ...(Object.prototype.hasOwnProperty.call(filter, 'isArchived') + ? {} + : { isArchived: { $ne: true } }), moderationStatus: { $ne: ModerationStatus.HIDDEN }, }; } @@ -40,6 +42,7 @@ export class PostsRepository { .findOne({ _id: new Types.ObjectId(postId), isDeleted: { $ne: true } }) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ path: 'mentionedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'repostOfPostId quoteOfPostId', @@ -57,6 +60,7 @@ export class PostsRepository { .findByIdAndUpdate(postId, payload, { new: true }) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ path: 'mentionedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'repostOfPostId quoteOfPostId', @@ -92,6 +96,7 @@ export class PostsRepository { .find(this.withActiveFilter(filter)) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ path: 'mentionedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'repostOfPostId quoteOfPostId', @@ -113,6 +118,7 @@ export class PostsRepository { .find(this.withAdminFilter(filter)) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ path: 'mentionedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) .sort(sort) .skip(skip) @@ -130,6 +136,7 @@ export class PostsRepository { .find({ _id: { $in: ids }, isDeleted: { $ne: true } }) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ path: 'mentionedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'repostOfPostId quoteOfPostId', @@ -220,6 +227,7 @@ export class PostsRepository { ) .populate({ path: 'authorId', select: 'name username avatar isVerified stageName' }) .populate({ path: 'taggedUserIds', select: 'name username avatar stageName isVerified' }) + .populate({ path: 'mentionedUserIds', select: 'name username avatar stageName isVerified' }) .populate({ path: 'collaboratorIds', select: 'name username avatar stageName isVerified' }) .exec(); } diff --git a/src/modules/posts/posts.service.spec.ts b/src/modules/posts/posts.service.spec.ts new file mode 100644 index 0000000..b839ed7 --- /dev/null +++ b/src/modules/posts/posts.service.spec.ts @@ -0,0 +1,122 @@ +import { Types } from 'mongoose'; +import { PostVisibility } from '../../common/enums/post-visibility.enum'; +import { PostSchema } from './schemas/post.schema'; +import { PostsService } from './posts.service'; + +const createService = () => { + const postsRepository = { + findMany: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + findById: jest.fn(), + updateById: jest.fn(), + }; + const connection = { + collection: jest.fn(() => ({ + countDocuments: jest.fn().mockResolvedValue(0), + })), + }; + + const service = new PostsService( + connection as any, + postsRepository as any, + {} as any, + {} as any, + {} as any, + {} as any, + {} as any, + { bumpGlobalVersion: jest.fn().mockResolvedValue(undefined) } as any, + {} as any, + {} as any, + ); + + return { service, postsRepository, connection }; +}; + +describe('PostsService archived profile posts', () => { + it('returns only archived posts for the owner when visibility=archived', async () => { + const userId = new Types.ObjectId().toString(); + const { service, postsRepository } = createService(); + postsRepository.findMany.mockResolvedValue([{ id: 'post-1', isArchived: true }]); + postsRepository.count.mockResolvedValue(1); + + const result = await service.findUserPosts( + userId, + { visibility: 'archived', page: 1, limit: 20 }, + userId, + ); + + expect(result.items).toHaveLength(1); + expect(postsRepository.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + authorId: new Types.ObjectId(userId), + isArchived: true, + }), + 0, + 20, + expect.objectContaining({ pinnedToProfile: -1 }), + ); + expect(postsRepository.count).toHaveBeenCalledWith( + expect.objectContaining({ + authorId: new Types.ObjectId(userId), + isArchived: true, + }), + ); + }); + + it('does not expose archived posts to another viewer', async () => { + const userId = new Types.ObjectId().toString(); + const viewerId = new Types.ObjectId().toString(); + const { service, postsRepository } = createService(); + + const result = await service.findUserPosts( + userId, + { visibility: 'archived', page: 1, limit: 20 }, + viewerId, + ); + + expect(result.items).toEqual([]); + expect(result.total).toBe(0); + expect(postsRepository.findMany).not.toHaveBeenCalled(); + }); + + it('keeps normal public profile filtering unchanged', async () => { + const userId = new Types.ObjectId().toString(); + const viewerId = new Types.ObjectId().toString(); + const { service, postsRepository } = createService(); + + await service.findUserPosts( + userId, + { visibility: PostVisibility.PUBLIC, page: 1, limit: 20 }, + viewerId, + ); + + expect(postsRepository.findMany).toHaveBeenCalledWith( + expect.objectContaining({ + authorId: new Types.ObjectId(userId), + isArchived: { $ne: true }, + visibility: PostVisibility.PUBLIC, + }), + 0, + 20, + expect.any(Object), + ); + }); +}); + +describe('Post schema response aliases', () => { + it('returns isPinned as a stable alias for pinnedToProfile', () => { + const transform = PostSchema.get('toObject')?.transform as ( + doc: unknown, + ret: Record, + ) => Record; + const ret = transform(null, { + postType: 'text', + pinnedToProfile: true, + waveformPeaks: [], + mentionedUserIds: [], + }); + + expect(ret.pinnedToProfile).toBe(true); + expect(ret.isPinned).toBe(true); + }); +}); diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index e7c7a7d..b6e0d0a 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -15,9 +15,10 @@ import { ProcessingStatus } from '../../common/enums/processing-status.enum'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { - generateWaveformPeaksFromBuffer, - generateWaveformPeaksFromSeed, - normalizeWaveformPeaks, + buildWaveformPeakSet, + generateWaveformPeakSetFromBuffer, + generateWaveformPeakSetFromSeed, + WaveformPeakSet, } from '../../common/utils/waveform.util'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { @@ -56,6 +57,17 @@ type NormalizedPostMediaMetadata = { maqam: string; rhythmSignature: string; waveformPeaks: number[]; + waveformPeaksPreview: number[]; + waveformPeaksDetailed: number[]; +}; + +type MentionTarget = { + id: string; + username: string; + name?: string; + stageName?: string; + avatar?: string; + isVerified?: boolean; }; type SavedVideoUpload = { @@ -162,6 +174,7 @@ export class PostsService { const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts); const mentionResolution = await this.resolveMentionTargets( dto.mentionUsernames, + dto.mentionedUserIds, finalContent, userId, ); @@ -193,6 +206,7 @@ export class PostsService { taggedUserIds, collaboratorIds, mentionUsernames: mentionResolution.mentionUsernames, + mentionedUserIds: mentionResolution.mentionedUserIds, location, latitude, longitude, @@ -234,7 +248,7 @@ export class PostsService { mentionResolution.mentionedUsers, finalContent, ); - return post; + return (await this.postsRepository.findById(post.id)) ?? post; } async update( @@ -371,13 +385,19 @@ export class PostsService { hasImageUpdate ? [] : (post.imageItems ?? []), ); const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []); + const previousMentionedUserIds = (post.mentionedUserIds ?? []).map( + (id: Types.ObjectId | string | { _id?: unknown; id?: unknown }) => this.extractEntityId(id), + ).filter(Boolean); const shouldRecomputeMentions = - typeof dto.content === 'string' || typeof dto.mentionUsernames !== 'undefined'; + typeof dto.content === 'string' || + typeof dto.mentionUsernames !== 'undefined' || + typeof dto.mentionedUserIds !== 'undefined'; const mentionResolution = shouldRecomputeMentions - ? await this.resolveMentionTargets(dto.mentionUsernames, nextContent, userId) + ? await this.resolveMentionTargets(dto.mentionUsernames, dto.mentionedUserIds, nextContent, userId) : { mentionUsernames: previousMentionUsernames, - mentionedUsers: [] as Array<{ id: string; username: string }>, + mentionedUserIds: previousMentionedUserIds.map((id) => new Types.ObjectId(id)), + mentionedUsers: [] as MentionTarget[], }; const { location: nextLocation, @@ -401,6 +421,8 @@ export class PostsService { maqam: post.maqam ?? '', rhythmSignature: post.rhythmSignature ?? '', waveformPeaks: post.waveformPeaks ?? [], + waveformPeaksPreview: post.waveformPeaksPreview ?? [], + waveformPeaksDetailed: post.waveformPeaksDetailed ?? [], }, { audioSourceBuffer: audioFile?.buffer, @@ -421,6 +443,7 @@ export class PostsService { taggedUserIds: nextTaggedUserIds, collaboratorIds: nextCollaboratorIds, mentionUsernames: mentionResolution.mentionUsernames, + mentionedUserIds: mentionResolution.mentionedUserIds, location: nextLocation, latitude: nextLatitude, longitude: nextLongitude, @@ -596,6 +619,18 @@ export class PostsService { authorId: new Types.ObjectId(userId), isArchived: { $ne: true }, }; + const archivedOnly = query.visibility === 'archived'; + if (archivedOnly) { + if (!viewerUserId || viewerUserId !== userId) { + return buildPaginatedResponse([], { + page, + limit, + total: 0, + offset: skip, + }); + } + filter.isArchived = true; + } if (viewerUserId && viewerUserId !== userId) { const isBlocked = await this.hasBlockBetween(viewerUserId, userId); if (isBlocked) { @@ -611,7 +646,7 @@ export class PostsService { ? { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] } : PostVisibility.PUBLIC; } - if (query.visibility) { + if (query.visibility && !archivedOnly) { filter.visibility = query.visibility; } if (query.postType) { @@ -1000,6 +1035,8 @@ export class PostsService { maqam: '', rhythmSignature: '', waveformPeaks: [], + waveformPeaksPreview: [], + waveformPeaksDetailed: [], }, options: { audioSourceBuffer?: Buffer; @@ -1024,6 +1061,8 @@ export class PostsService { maqam: '', rhythmSignature: '', waveformPeaks: [], + waveformPeaksPreview: [], + waveformPeaksDetailed: [], }; } @@ -1031,13 +1070,29 @@ export class PostsService { throw new BadRequestException('waveformPeaks is allowed only for audio posts'); } + const durationSeconds = + typeof options.extractedDurationSeconds === 'number' + ? options.extractedDurationSeconds + : typeof dto.durationSeconds === 'number' + ? dto.durationSeconds + : fallback.durationSeconds; + const waveformSet = supportsWaveform + ? this.resolveAudioWaveformPeaks( + Array.isArray(dto.waveformPeaks) ? dto.waveformPeaks : undefined, + options.audioSourceBuffer, + options.waveformSeed, + { + waveformPeaks: fallback.waveformPeaks, + waveformPeaksPreview: fallback.waveformPeaksPreview, + waveformPeaksDetailed: fallback.waveformPeaksDetailed, + }, + durationSeconds, + ) + : { waveformPeaks: [], waveformPeaksPreview: [], waveformPeaksDetailed: [] }; + return { durationSeconds: - typeof options.extractedDurationSeconds === 'number' - ? options.extractedDurationSeconds - : typeof dto.durationSeconds === 'number' - ? dto.durationSeconds - : fallback.durationSeconds, + durationSeconds, thumbnailUrl: typeof dto.thumbnailUrl === 'string' ? dto.thumbnailUrl.trim() @@ -1048,14 +1103,7 @@ export class PostsService { typeof dto.rhythmSignature === 'string' ? dto.rhythmSignature.trim() : fallback.rhythmSignature, - waveformPeaks: supportsWaveform - ? this.resolveAudioWaveformPeaks( - Array.isArray(dto.waveformPeaks) ? dto.waveformPeaks : undefined, - options.audioSourceBuffer, - options.waveformSeed, - fallback.waveformPeaks, - ) - : [], + ...waveformSet, }; } @@ -1090,11 +1138,13 @@ export class PostsService { private async resolveMentionTargets( explicitMentionUsernames: string[] | undefined, + explicitMentionedUserIds: string[] | undefined, content: string, authorId: string, ): Promise<{ mentionUsernames: string[]; - mentionedUsers: Array<{ id: string; username: string }>; + mentionedUserIds: Types.ObjectId[]; + mentionedUsers: MentionTarget[]; }> { const mergedMentionUsernames = Array.from( new Set([ @@ -1107,29 +1157,78 @@ export class PostsService { throw new BadRequestException('You can mention up to 30 users only'); } - if (!mergedMentionUsernames.length) { - return { mentionUsernames: [], mentionedUsers: [] }; + const mentionedIds = this.normalizeMentionedUserIds(explicitMentionedUserIds, authorId); + if (mergedMentionUsernames.length + mentionedIds.length > 30) { + throw new BadRequestException('You can mention up to 30 users only'); } - const users = await this.usersRepository.findByUsernames(mergedMentionUsernames); + if (!mergedMentionUsernames.length && !mentionedIds.length) { + return { mentionUsernames: [], mentionedUserIds: [], mentionedUsers: [] }; + } + + const [usersByUsername, usersById] = await Promise.all([ + mergedMentionUsernames.length + ? this.usersRepository.findByUsernames(mergedMentionUsernames) + : Promise.resolve([]), + mentionedIds.length + ? this.usersRepository.findMany( + { _id: { $in: mentionedIds }, isDisabled: false }, + 0, + mentionedIds.length, + ) + : Promise.resolve([]), + ]); const userByUsername = new Map( - users.map((user) => [ - user.username.toLowerCase(), - { id: user.id, username: user.username.toLowerCase() }, - ]), + usersByUsername + .filter((user) => !user.isDisabled) + .map((user) => [ + user.username.toLowerCase(), + this.toMentionTarget(user), + ]), ); - const mentionedUsers = mergedMentionUsernames - .map((username) => userByUsername.get(username)) - .filter((user): user is { id: string; username: string } => !!user) - .filter((user) => user.id !== authorId); + const mentionedUsersById = new Map(); + for (const user of [ + ...mergedMentionUsernames + .map((username) => userByUsername.get(username)) + .filter((user): user is MentionTarget => !!user), + ...usersById.map((user) => this.toMentionTarget(user)), + ]) { + if (user.id !== authorId && user.username) { + mentionedUsersById.set(user.id, user); + } + } + + const mentionedUsers = Array.from(mentionedUsersById.values()).slice(0, 30); return { - mentionUsernames: mentionedUsers.map((user) => user.username), + mentionUsernames: mentionedUsers.map((user) => user.username).filter(Boolean), + mentionedUserIds: mentionedUsers.map((user) => new Types.ObjectId(user.id)), mentionedUsers, }; } + private normalizeMentionedUserIds(input: string[] | undefined, authorId: string): string[] { + return Array.from( + new Set( + (input ?? []) + .map((id) => id?.trim()) + .filter((id): id is string => !!id && id !== authorId && Types.ObjectId.isValid(id)), + ), + ); + } + + private toMentionTarget(user: any): MentionTarget { + return { + id: user.id ?? user._id?.toString?.() ?? '', + username: user.username?.toLowerCase?.() ?? '', + name: user.name ?? '', + stageName: user.stageName ?? '', + avatar: user.avatar ?? '', + isVerified: user.isVerified ?? false, + }; + } + private extractHashtags(content: string): string[] { const matches = content.match(/#[\p{L}\p{N}_]+/gu) ?? []; const normalized = matches @@ -1363,7 +1462,7 @@ export class PostsService { } const content = dto.content?.trim() ?? ''; - const mentionResolution = await this.resolveMentionTargets(undefined, content, userId); + const mentionResolution = await this.resolveMentionTargets(undefined, undefined, content, userId); const hashtags = this.extractHashtags(content); const post = await this.postsRepository.create(userId, { content, @@ -1373,6 +1472,7 @@ export class PostsService { repostOfPostId: content ? null : new Types.ObjectId(sourcePostId), quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null, mentionUsernames: mentionResolution.mentionUsernames, + mentionedUserIds: mentionResolution.mentionedUserIds, hashtags, }); @@ -1664,21 +1764,30 @@ export class PostsService { providedPeaks: number[] | undefined, sourceBuffer: Buffer | undefined, waveformSeed: string | undefined, - fallbackPeaks: number[] = [], - ): number[] { + fallbackPeaks: Partial = {}, + durationSeconds?: number | null, + ): WaveformPeakSet { if (Array.isArray(providedPeaks) && providedPeaks.length) { - return normalizeWaveformPeaks(providedPeaks); + return buildWaveformPeakSet(providedPeaks, durationSeconds); } if (sourceBuffer?.length) { - return generateWaveformPeaksFromBuffer(sourceBuffer); + return generateWaveformPeakSetFromBuffer(sourceBuffer, durationSeconds); } - if (fallbackPeaks.length) { - return normalizeWaveformPeaks(fallbackPeaks); + if (fallbackPeaks.waveformPeaksDetailed?.length) { + return buildWaveformPeakSet(fallbackPeaks.waveformPeaksDetailed, durationSeconds); } - return generateWaveformPeaksFromSeed(waveformSeed ?? 'audio-post'); + if (fallbackPeaks.waveformPeaksPreview?.length) { + return buildWaveformPeakSet(fallbackPeaks.waveformPeaksPreview, durationSeconds); + } + + if (fallbackPeaks.waveformPeaks?.length) { + return buildWaveformPeakSet(fallbackPeaks.waveformPeaks, durationSeconds); + } + + return generateWaveformPeakSetFromSeed(waveformSeed ?? 'audio-post', durationSeconds); } private async assertPostOwner(userId: string, postId: string): Promise { diff --git a/src/modules/posts/schemas/post.schema.ts b/src/modules/posts/schemas/post.schema.ts index a40cd7e..9e0b913 100644 --- a/src/modules/posts/schemas/post.schema.ts +++ b/src/modules/posts/schemas/post.schema.ts @@ -11,7 +11,7 @@ import { resolveManagedFileUrlRecords, resolveManagedFileUrls, } from '../../../common/utils/public-url.util'; -import { normalizeWaveformPeaks } from '../../../common/utils/waveform.util'; +import { buildWaveformPeakSet } from '../../../common/utils/waveform.util'; import { User } from '../../users/schemas/user.schema'; export type PostDocument = HydratedDocument; @@ -88,6 +88,12 @@ export class Post { @Prop({ type: [Number], default: [] }) waveformPeaks!: number[]; + @Prop({ type: [Number], default: [] }) + waveformPeaksPreview!: number[]; + + @Prop({ type: [Number], default: [] }) + waveformPeaksDetailed!: number[]; + @Prop({ type: [String], default: [] }) imageUrls!: string[]; @@ -103,6 +109,9 @@ export class Post { @Prop({ type: [String], default: [] }) mentionUsernames!: string[]; + @Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true }) + mentionedUserIds!: Types.ObjectId[]; + @Prop({ type: [Types.ObjectId], ref: User.name, default: [], index: true }) collaboratorIds!: Types.ObjectId[]; @@ -196,6 +205,7 @@ PostSchema.index({ postType: 1, createdAt: -1 }); PostSchema.index({ processingStatus: 1, createdAt: -1 }); PostSchema.index({ hashtags: 1, createdAt: -1 }); PostSchema.index({ taggedUserIds: 1, createdAt: -1 }); +PostSchema.index({ mentionedUserIds: 1, createdAt: -1 }); PostSchema.index({ collaboratorIds: 1, createdAt: -1 }); PostSchema.index({ authorId: 1, pinnedToProfile: -1, createdAt: -1 }); PostSchema.index({ authorId: 1, isArchived: 1, createdAt: -1 }); @@ -238,12 +248,40 @@ const transformManagedPostFiles = (_doc: unknown, ret: any) => { ret.audioUrl = resolveManagedFileUrl(ret.audioUrl); ret.thumbnailUrl = resolveManagedFileUrl(ret.thumbnailUrl); ret.thumbnailVariants = resolveManagedFileUrlRecord(ret.thumbnailVariants); - ret.waveformPeaks = + const waveformSet = ret.postType === PostType.AUDIO || ret.audioUrl - ? normalizeWaveformPeaks(ret.waveformPeaks) - : Array.isArray(ret.waveformPeaks) - ? ret.waveformPeaks - : []; + ? buildWaveformPeakSet( + Array.isArray(ret.waveformPeaksDetailed) && ret.waveformPeaksDetailed.length + ? ret.waveformPeaksDetailed + : Array.isArray(ret.waveformPeaksPreview) && ret.waveformPeaksPreview.length + ? ret.waveformPeaksPreview + : ret.waveformPeaks, + ret.durationSeconds, + ) + : { waveformPeaks: [], waveformPeaksPreview: [], waveformPeaksDetailed: [] }; + ret.waveformPeaks = waveformSet.waveformPeaks; + ret.waveformPeaksPreview = waveformSet.waveformPeaksPreview; + ret.waveformPeaksDetailed = waveformSet.waveformPeaksDetailed; + ret.mentionedUsers = Array.isArray(ret.mentionedUserIds) + ? ret.mentionedUserIds + .map((user: any) => { + if (!user || typeof user !== 'object' || !('username' in user)) { + return null; + } + const id = user._id?.toString?.() ?? user.id?.toString?.() ?? ''; + return { + _id: id, + id, + username: user.username ?? '', + name: user.name ?? '', + stageName: user.stageName ?? '', + avatar: resolveManagedFileUrl(user.avatar ?? ''), + isVerified: user.isVerified ?? false, + }; + }) + .filter(Boolean) + : []; + ret.isPinned = !!ret.pinnedToProfile; ret.processingStatus = ret.processingStatus ?? ProcessingStatus.READY; ret.media = buildPostMediaResponse(ret); return ret; diff --git a/src/modules/users/users.controller.spec.ts b/src/modules/users/users.controller.spec.ts index 3c94e53..ce9eb2e 100644 --- a/src/modules/users/users.controller.spec.ts +++ b/src/modules/users/users.controller.spec.ts @@ -32,3 +32,25 @@ describe('UsersController profile lookup', () => { expect(result).toEqual({ profileShareUrl: 'https://oudelaa.com/u/artist' }); }); }); + +describe('UsersController follow counts', () => { + it('uses the authenticated user id when loading follow counts', async () => { + const usersService = { + getFollowCounts: jest.fn().mockResolvedValue({ + userId: 'target-1', + followersCount: 1, + followingCount: 2, + }), + }; + const controller = new UsersController(usersService as any); + + const result = await controller.getFollowCounts({ sub: 'viewer-1' } as any, 'target-1'); + + expect(usersService.getFollowCounts).toHaveBeenCalledWith('target-1', 'viewer-1'); + expect(result).toEqual({ + userId: 'target-1', + followersCount: 1, + followingCount: 2, + }); + }); +}); diff --git a/src/modules/users/users.controller.ts b/src/modules/users/users.controller.ts index ad11aef..98b9a63 100644 --- a/src/modules/users/users.controller.ts +++ b/src/modules/users/users.controller.ts @@ -298,6 +298,13 @@ export class UsersController { return this.usersService.getProfileOverviewByUsername(username, user.sub); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get(':id/follow-counts') + async getFollowCounts(@CurrentUser() user: JwtPayload, @Param('id') id: string) { + return this.usersService.getFollowCounts(id, user.sub); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get(':id/profile-overview') diff --git a/src/modules/users/users.service.spec.ts b/src/modules/users/users.service.spec.ts index 6b9152d..7183a5f 100644 --- a/src/modules/users/users.service.spec.ts +++ b/src/modules/users/users.service.spec.ts @@ -67,6 +67,12 @@ const createService = (options: { .mockResolvedValueOnce(options.newFollowersThisWeek ?? 0) .mockResolvedValueOnce(options.newFollowersThisMonth ?? 0), }; + const followRequestsCollection = { + countDocuments: jest.fn().mockResolvedValue(0), + }; + const blocksCollection = { + countDocuments: jest.fn().mockResolvedValue(0), + }; const notificationsCollection = { find: jest.fn().mockReturnValue(chain(options.recentActivity ?? [])), }; @@ -78,6 +84,12 @@ const createService = (options: { if (name === 'follows') { return followsCollection; } + if (name === 'followrequests') { + return followRequestsCollection; + } + if (name === 'blocks') { + return blocksCollection; + } if (name === 'notifications') { return notificationsCollection; } @@ -96,7 +108,15 @@ const createService = (options: { { saveFile: jest.fn(), deleteFile: jest.fn() } as any, ); - return { service, userId, usersRepository, postsCollection, followsCollection }; + return { + service, + userId, + usersRepository, + postsCollection, + followsCollection, + followRequestsCollection, + blocksCollection, + }; }; describe('UsersService artist dashboard', () => { @@ -188,6 +208,55 @@ describe('UsersService artist dashboard', () => { }); }); +describe('UsersService follow counts', () => { + it('returns follower counts and viewer state for another profile', async () => { + const viewerId = new Types.ObjectId().toString(); + const { service, userId, followsCollection, followRequestsCollection, blocksCollection } = + createService(); + followsCollection.countDocuments = jest + .fn() + .mockResolvedValueOnce(23) + .mockResolvedValueOnce(122) + .mockResolvedValueOnce(1); + followRequestsCollection.countDocuments = jest.fn().mockResolvedValueOnce(1); + blocksCollection.countDocuments = jest.fn().mockResolvedValueOnce(0).mockResolvedValueOnce(0); + + const result = await service.getFollowCounts(userId, viewerId); + + expect(result).toEqual({ + userId, + followersCount: 23, + followingCount: 122, + followRequestsCount: 0, + isPrivate: false, + viewerState: { + isOwnProfile: false, + isFollowing: true, + hasPendingFollowRequest: true, + isBlocked: false, + isBlockedByUser: false, + }, + }); + }); + + it('returns pending follow requests count only for own profile', async () => { + const { service, userId, followsCollection, followRequestsCollection } = createService(); + followsCollection.countDocuments = jest.fn().mockResolvedValueOnce(3).mockResolvedValueOnce(5); + followRequestsCollection.countDocuments = jest.fn().mockResolvedValueOnce(7); + + const result = await service.getFollowCounts(userId, userId); + + expect(result.followRequestsCount).toBe(7); + expect(result.viewerState).toMatchObject({ + isOwnProfile: true, + isFollowing: false, + hasPendingFollowRequest: false, + isBlocked: false, + isBlockedByUser: false, + }); + }); +}); + describe('UsersService profile sharing', () => { it('returns profileShareUrl with username in profile overview', async () => { const { service, userId } = createService({ publicAppUrl: 'https://oudelaa.com/' }); diff --git a/src/modules/users/users.service.ts b/src/modules/users/users.service.ts index a9b4881..fba2fee 100644 --- a/src/modules/users/users.service.ts +++ b/src/modules/users/users.service.ts @@ -810,6 +810,79 @@ export class UsersService { return this.getProfileOverview(this.extractUserId(user), viewerUserId); } + async getFollowCounts(userId: string, viewerUserId: string) { + if (!Types.ObjectId.isValid(userId) || !Types.ObjectId.isValid(viewerUserId)) { + throw new BadRequestException('Invalid user id'); + } + + const user = await this.findPublicByIdOrFail(userId); + const objectUserId = new Types.ObjectId(userId); + const objectViewerUserId = new Types.ObjectId(viewerUserId); + const isOwnProfile = userId === viewerUserId; + const followsCollection = this.connection.collection('follows'); + const followRequestsCollection = this.connection.collection('followrequests'); + const blocksCollection = this.connection.collection('blocks'); + + const [ + followersCount, + followingCount, + followRequestsCount, + isFollowingCount, + pendingRequestCount, + viewerBlockedTargetCount, + targetBlockedViewerCount, + ] = await Promise.all([ + followsCollection.countDocuments({ followingId: objectUserId }), + followsCollection.countDocuments({ followerId: objectUserId }), + isOwnProfile + ? followRequestsCollection.countDocuments({ + targetUserId: objectUserId, + status: 'pending', + }) + : Promise.resolve(0), + isOwnProfile + ? Promise.resolve(0) + : followsCollection.countDocuments({ + followerId: objectViewerUserId, + followingId: objectUserId, + }), + isOwnProfile + ? Promise.resolve(0) + : followRequestsCollection.countDocuments({ + requesterId: objectViewerUserId, + targetUserId: objectUserId, + status: 'pending', + }), + isOwnProfile + ? Promise.resolve(0) + : blocksCollection.countDocuments({ + blockerId: objectViewerUserId, + blockedId: objectUserId, + }), + isOwnProfile + ? Promise.resolve(0) + : blocksCollection.countDocuments({ + blockerId: objectUserId, + blockedId: objectViewerUserId, + }), + ]); + + return { + userId, + followersCount, + followingCount, + followRequestsCount, + isPrivate: user.isPrivate ?? false, + viewerState: { + isOwnProfile, + isFollowing: isFollowingCount > 0, + hasPendingFollowRequest: pendingRequestCount > 0, + isBlocked: viewerBlockedTargetCount > 0, + isBlockedByUser: targetBlockedViewerCount > 0, + }, + }; + } + async getMyDashboard(currentUserId: string): Promise { if (!Types.ObjectId.isValid(currentUserId)) { throw new BadRequestException('Invalid user id');