diff --git a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json index 5516391..225b256 100644 --- a/postman/Oudelaa-Auth-Users-Posts.postman_collection.json +++ b/postman/Oudelaa-Auth-Users-Posts.postman_collection.json @@ -1898,9 +1898,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/video-cover.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -1964,7 +1964,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('video');", "pm.expect(json.durationSeconds).to.eql(42);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/video-cover.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Sharqi');", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.expect(json.rhythmSignature).to.eql('6/8');", @@ -2005,9 +2005,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/reel-cover.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -2051,7 +2051,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('video');", "pm.expect(json.durationSeconds).to.eql(30);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/reel-cover.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Sharqi');", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.expect(json.rhythmSignature).to.eql('6/8');", @@ -2146,9 +2146,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/audio-cover.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -2192,7 +2192,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.durationSeconds).to.eql(54);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/audio-cover.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Sharqi');", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.expect(json.rhythmSignature).to.eql('6/8');", @@ -2404,9 +2404,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/video-cover-updated.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -2453,7 +2453,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('video');", "pm.expect(json.durationSeconds).to.eql(58);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/video-cover-updated.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Contemporary');", "pm.expect(json.maqam).to.eql('Bayati');", "pm.expect(json.rhythmSignature).to.eql('4/4');" @@ -2487,9 +2487,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/audio-cover-updated.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -2536,7 +2536,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.durationSeconds).to.eql(61);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/audio-cover-updated.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Tarab');", "pm.expect(json.maqam).to.eql('Nahawand');", "pm.expect(json.rhythmSignature).to.eql('6/8');" @@ -8606,4 +8606,4 @@ "value": "" } ] -} \ No newline at end of file +} diff --git a/postman/Oudelaa-Mobile.postman_collection.json b/postman/Oudelaa-Mobile.postman_collection.json index 5bebc1c..4fe465b 100644 --- a/postman/Oudelaa-Mobile.postman_collection.json +++ b/postman/Oudelaa-Mobile.postman_collection.json @@ -1359,9 +1359,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/video-cover.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -1425,7 +1425,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('video');", "pm.expect(json.durationSeconds).to.eql(42);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/video-cover.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Sharqi');", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.expect(json.rhythmSignature).to.eql('6/8');", @@ -1466,9 +1466,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/reel-cover.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -1512,7 +1512,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('video');", "pm.expect(json.durationSeconds).to.eql(30);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/reel-cover.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Sharqi');", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.expect(json.rhythmSignature).to.eql('6/8');", @@ -1607,9 +1607,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/audio-cover.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -1653,7 +1653,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.durationSeconds).to.eql(54);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/audio-cover.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Sharqi');", "pm.expect(json.maqam).to.eql('Hijaz');", "pm.expect(json.rhythmSignature).to.eql('6/8');", @@ -1865,9 +1865,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/video-cover-updated.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -1914,7 +1914,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('video');", "pm.expect(json.durationSeconds).to.eql(58);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/video-cover-updated.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Contemporary');", "pm.expect(json.maqam).to.eql('Bayati');", "pm.expect(json.rhythmSignature).to.eql('4/4');" @@ -1948,9 +1948,9 @@ "type": "text" }, { - "key": "thumbnailUrl", - "value": "https://cdn.example.com/audio-cover-updated.jpg", - "type": "text" + "key": "coverImageFile", + "type": "file", + "src": [] }, { "key": "style", @@ -1997,7 +1997,7 @@ "const json = pm.response.json();", "pm.expect(json.postType).to.eql('audio');", "pm.expect(json.durationSeconds).to.eql(61);", - "pm.expect(json.thumbnailUrl).to.eql('https://cdn.example.com/audio-cover-updated.jpg');", + "if (json.thumbnailUrl) { pm.expect(json.thumbnailUrl).to.be.a('string'); }", "pm.expect(json.style).to.eql('Tarab');", "pm.expect(json.maqam).to.eql('Nahawand');", "pm.expect(json.rhythmSignature).to.eql('6/8');" @@ -5832,4 +5832,4 @@ "value": "" } ] -} \ No newline at end of file +} diff --git a/src/modules/posts/posts.controller.ts b/src/modules/posts/posts.controller.ts index 6e47b32..b9bb22d 100644 --- a/src/modules/posts/posts.controller.ts +++ b/src/modules/posts/posts.controller.ts @@ -45,6 +45,7 @@ export class PostsController { { name: 'imageFiles', maxCount: 10 }, { name: 'videoFile', maxCount: 1 }, { name: 'audioFile', maxCount: 1 }, + { name: 'coverImageFile', maxCount: 1 }, ]), ) @ApiConsumes('multipart/form-data') @@ -76,6 +77,7 @@ export class PostsController { imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } }, videoFile: { type: 'string', format: 'binary' }, audioFile: { type: 'string', format: 'binary' }, + coverImageFile: { type: 'string', format: 'binary' }, }, }, }) @@ -88,6 +90,7 @@ export class PostsController { imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; audioFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + coverImageFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; }, ) { return this.postsService.create( @@ -96,6 +99,7 @@ export class PostsController { files?.imageFiles ?? [], files?.videoFile?.[0], files?.audioFile?.[0], + files?.coverImageFile?.[0], ); } @@ -119,6 +123,7 @@ export class PostsController { @UseInterceptors( FileFieldsInterceptor([ { name: 'videoFile', maxCount: 1 }, + { name: 'coverImageFile', maxCount: 1 }, ]), ) @ApiConsumes('multipart/form-data') @@ -136,6 +141,7 @@ export class PostsController { rhythmSignature: { type: 'string', example: '6/8' }, mentionUsernames: { type: 'array', items: { type: 'string' } }, videoFile: { type: 'string', format: 'binary' }, + coverImageFile: { type: 'string', format: 'binary' }, }, }, }) @@ -146,9 +152,10 @@ export class PostsController { @UploadedFiles() files?: { videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + coverImageFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; }, ) { - return this.postsService.createReel(user.sub, dto, files?.videoFile?.[0]); + return this.postsService.createReel(user.sub, dto, files?.videoFile?.[0], files?.coverImageFile?.[0]); } @ApiBearerAuth() @@ -172,6 +179,7 @@ export class PostsController { { name: 'imageFiles', maxCount: 10 }, { name: 'videoFile', maxCount: 1 }, { name: 'audioFile', maxCount: 1 }, + { name: 'coverImageFile', maxCount: 1 }, ]), ) @ApiConsumes('multipart/form-data') @@ -203,6 +211,7 @@ export class PostsController { imageFiles: { type: 'array', items: { type: 'string', format: 'binary' } }, videoFile: { type: 'string', format: 'binary' }, audioFile: { type: 'string', format: 'binary' }, + coverImageFile: { type: 'string', format: 'binary' }, }, }, }) @@ -216,6 +225,7 @@ export class PostsController { imageFiles?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; videoFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; audioFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; + coverImageFile?: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>; }, ) { return this.postsService.update( @@ -225,6 +235,7 @@ export class PostsController { files?.imageFiles ?? [], files?.videoFile?.[0], files?.audioFile?.[0], + files?.coverImageFile?.[0], ); } diff --git a/src/modules/posts/posts.service.ts b/src/modules/posts/posts.service.ts index 2ea9929..7d533a2 100644 --- a/src/modules/posts/posts.service.ts +++ b/src/modules/posts/posts.service.ts @@ -87,6 +87,7 @@ export class PostsService { imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [], videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, ): Promise { const inputImageUrls = dto.imageUrls ?? []; if (inputImageUrls.length > 10 || imageFiles.length > 10) { @@ -104,6 +105,12 @@ export class PostsService { if (audioFile && dto.audioUrl) { throw new BadRequestException('Provide either audioFile or audioUrl, not both'); } + if (coverImageFile && dto.thumbnailUrl) { + throw new BadRequestException('Provide either coverImageFile or thumbnailUrl, not both'); + } + if (coverImageFile && !(videoFile || audioFile || dto.videoUrl || dto.audioUrl)) { + throw new BadRequestException('coverImageFile is allowed only with video or audio posts'); + } if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) { throw new BadRequestException('Post can contain either images or video, not both'); } @@ -115,10 +122,15 @@ export class PostsService { const uploadedImageUrls = savedImageUploads.map((item) => item.primaryUrl); const uploadedImageVariants = savedImageUploads.map((item) => item.variants); const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null; + const savedCoverImageUpload = coverImageFile + ? await this.saveResponsiveImageAsset('thumbnails', coverImageFile) + : null; const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? ''; const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? ''; - const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? ''; - const uploadedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; + const generatedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? ''; + const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; + const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl; + const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants; const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls; const finalImageVariants = uploadedImageVariants.length ? uploadedImageVariants : []; @@ -174,11 +186,18 @@ export class PostsService { uploadedThumbnailUrl ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) : Promise.resolve(), + generatedThumbnailUrl && generatedThumbnailUrl !== uploadedThumbnailUrl + ? this.deleteThumbnailAsset(generatedThumbnailUrl, generatedThumbnailVariants) + : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), ]); throw error; } + if (generatedThumbnailUrl && generatedThumbnailUrl !== uploadedThumbnailUrl) { + await this.deleteThumbnailAsset(generatedThumbnailUrl, generatedThumbnailVariants); + } + await this.usersRepository.incrementPostsCount(userId, 1); await this.feedVersionService.bumpGlobalVersion(); await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, finalContent); @@ -192,6 +211,7 @@ export class PostsService { imageFiles: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }> = [], videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, audioFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, ): Promise { const post = await this.postsRepository.findById(postId); if (!post) { @@ -219,6 +239,15 @@ export class PostsService { if (audioFile && dto.audioUrl) { throw new BadRequestException('Provide either audioFile or audioUrl, not both'); } + if (coverImageFile && dto.thumbnailUrl) { + throw new BadRequestException('Provide either coverImageFile or thumbnailUrl, not both'); + } + if (coverImageFile && (imageFiles.length || inputImageUrls.length)) { + throw new BadRequestException('coverImageFile is allowed only with video or audio posts'); + } + if (coverImageFile && !(videoFile || audioFile || dto.videoUrl || dto.audioUrl || post.videoUrl || post.audioUrl)) { + throw new BadRequestException('coverImageFile is allowed only with video or audio posts'); + } if ((videoFile || dto.videoUrl) && (imageFiles.length || inputImageUrls.length)) { throw new BadRequestException('Post can contain either images or video, not both'); } @@ -230,10 +259,15 @@ export class PostsService { const uploadedImageUrls = savedImageUploads.map((item) => item.primaryUrl); const uploadedImageVariants = savedImageUploads.map((item) => item.variants); const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null; + const savedCoverImageUpload = coverImageFile + ? await this.saveResponsiveImageAsset('thumbnails', coverImageFile) + : null; const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? ''; const uploadedHlsUrl = savedVideoUpload?.hlsUrl ?? ''; - const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? ''; - const uploadedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; + const generatedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? ''; + const generatedThumbnailVariants = savedVideoUpload?.thumbnailVariants ?? null; + const uploadedThumbnailUrl = savedCoverImageUpload?.primaryUrl ?? generatedThumbnailUrl; + const uploadedThumbnailVariants = savedCoverImageUpload?.variants ?? generatedThumbnailVariants; const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : ''; const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0; const existingImageVariants = Array.isArray((post as any).imageVariants) @@ -266,7 +300,7 @@ export class PostsService { ? uploadedAudioUrl : dto.audioUrl ?? '' : post.audioUrl ?? ''; - const nextThumbnailVariants = videoFile + const nextThumbnailVariants = coverImageFile || videoFile ? uploadedThumbnailVariants : typeof dto.thumbnailUrl === 'string' ? null @@ -392,6 +426,9 @@ export class PostsService { uploadedThumbnailUrl ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) : Promise.resolve(), + generatedThumbnailUrl && generatedThumbnailUrl !== uploadedThumbnailUrl + ? this.deleteThumbnailAsset(generatedThumbnailUrl, generatedThumbnailVariants) + : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), ]); throw error; @@ -404,6 +441,9 @@ export class PostsService { uploadedThumbnailUrl ? this.deleteThumbnailAsset(uploadedThumbnailUrl, uploadedThumbnailVariants) : Promise.resolve(), + generatedThumbnailUrl && generatedThumbnailUrl !== uploadedThumbnailUrl + ? this.deleteThumbnailAsset(generatedThumbnailUrl, generatedThumbnailVariants) + : Promise.resolve(), uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(), ]); throw new NotFoundException('Post not found'); @@ -418,6 +458,9 @@ export class PostsService { if ((post.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) { await this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants); } + if (generatedThumbnailUrl && generatedThumbnailUrl !== uploadedThumbnailUrl) { + await this.deleteThumbnailAsset(generatedThumbnailUrl, generatedThumbnailVariants); + } if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) { await this.deleteManagedPostMedia(post.audioUrl ?? ''); } @@ -567,6 +610,7 @@ export class PostsService { userId: string, dto: CreateReelDto, videoFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, + coverImageFile?: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, ): Promise { if (!videoFile && !dto.videoUrl) { throw new BadRequestException('Reel requires videoFile or videoUrl'); @@ -592,6 +636,7 @@ export class PostsService { [], videoFile, undefined, + coverImageFile, ); }