Add chat hiding mentions follow counts waveform and post fixes
هذا الالتزام موجود في:
@@ -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": {
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -52,6 +52,39 @@ export class ChatRepository {
|
||||
});
|
||||
}
|
||||
|
||||
private visibleConversationFilterForUser(userId: string): FilterQuery<ConversationDocument> {
|
||||
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<string, 1 | -1> = { lastMessageAt: -1, updatedAt: -1 },
|
||||
): Promise<ConversationDocument[]> {
|
||||
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<number> {
|
||||
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<ConversationDocument | null> {
|
||||
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: {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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()));
|
||||
|
||||
@@ -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<Conversation>;
|
||||
|
||||
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<string, number>;
|
||||
|
||||
@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<string, number> => {
|
||||
if (!value) {
|
||||
@@ -60,6 +69,12 @@ const normalizeUnreadCountByUser = (value: unknown): Record<string, number> => {
|
||||
|
||||
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;
|
||||
};
|
||||
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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 '';
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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',
|
||||
);
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
122
src/modules/posts/posts.service.spec.ts
Normal file
122
src/modules/posts/posts.service.spec.ts
Normal file
@@ -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<string, any>,
|
||||
) => Record<string, any>;
|
||||
const ret = transform(null, {
|
||||
postType: 'text',
|
||||
pinnedToProfile: true,
|
||||
waveformPeaks: [],
|
||||
mentionedUserIds: [],
|
||||
});
|
||||
|
||||
expect(ret.pinnedToProfile).toBe(true);
|
||||
expect(ret.isPinned).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -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<string, MentionTarget>();
|
||||
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<WaveformPeakSet> = {},
|
||||
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<PostDocument> {
|
||||
|
||||
@@ -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<Post>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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/' });
|
||||
|
||||
@@ -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<ArtistDashboardResponse> {
|
||||
if (!Types.ObjectId.isValid(currentUserId)) {
|
||||
throw new BadRequestException('Invalid user id');
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم