Add chat hiding mentions follow counts waveform and post fixes

هذا الالتزام موجود في:
boutmoun123
2026-06-15 15:05:30 +03:00
الأصل 20fe06b5ed
التزام 3d2a41b22a
28 ملفات معدلة مع 1352 إضافات و75 حذوفات

عرض الملف

@@ -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();
}

عرض الملف

@@ -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');
}
return {
durationSeconds:
const durationSeconds =
typeof options.extractedDurationSeconds === 'number'
? options.extractedDurationSeconds
: typeof dto.durationSeconds === 'number'
? dto.durationSeconds
: fallback.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:
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) => [
usersByUsername
.filter((user) => !user.isDisabled)
.map((user) => [
user.username.toLowerCase(),
{ id: user.id, username: user.username.toLowerCase() },
this.toMentionTarget(user),
]),
);
const mentionedUsers = mergedMentionUsernames
const mentionedUsersById = new Map<string, MentionTarget>();
for (const user of [
...mergedMentionUsernames
.map((username) => userByUsername.get(username))
.filter((user): user is { id: string; username: string } => !!user)
.filter((user) => user.id !== authorId);
.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');