import { BadRequestException, ForbiddenException, Injectable, Logger, NotFoundException } from '@nestjs/common'; import { extname } from 'path'; import { Types } from 'mongoose'; import { ModerationStatus } from '../../common/enums/moderation-status.enum'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { resolveMongoSortDirection } from '../../common/utils/sort.util'; import { generateWaveformPeaksFromBuffer, generateWaveformPeaksFromSeed, normalizeWaveformPeaks, } from '../../common/utils/waveform.util'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { ImageProcessingService, UploadedImageFile, } from '../../infrastructure/storage/image-processing.service'; import { ManagedStorageService } from '../../infrastructure/storage/managed-storage.service'; import { UploadedVideoFile, VideoProcessingService, } from '../../infrastructure/storage/video-processing.service'; import { NotificationsService } from '../notifications/notifications.service'; import { AuditService } from '../audit/audit.service'; import { UsersRepository } from '../users/users.repository'; import { AdminPostQueryDto } from './dto/admin-post-query.dto'; import { CreateReelDto } from './dto/create-reel.dto'; import { CreatePostDto } from './dto/create-post.dto'; import { CreateRepostDto } from './dto/create-repost.dto'; import { PostQueryDto } from './dto/post-query.dto'; import { ReelQueryDto } from './dto/reel-query.dto'; import { UpdateCommentSettingsDto } from './dto/update-comment-settings.dto'; import { UpdatePostDto } from './dto/update-post.dto'; import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema'; import { PostsRepository } from './posts.repository'; type PostMediaMetadataInput = Pick< CreatePostDto, | 'durationSeconds' | 'thumbnailUrl' | 'style' | 'maqam' | 'rhythmSignature' | 'waveformPeaks' >; type NormalizedPostMediaMetadata = { durationSeconds: number | null; thumbnailUrl: string; style: string; maqam: string; rhythmSignature: string; waveformPeaks: number[]; }; type SavedVideoUpload = { videoUrl: string; hlsUrl: string; thumbnailUrl: string; thumbnailVariants: PostMediaVariantSet | null; }; type SavedImageUpload = { primaryUrl: string; variants: PostMediaVariantSet; }; @Injectable() export class PostsService { private readonly logger = new Logger(PostsService.name); constructor( private readonly postsRepository: PostsRepository, private readonly usersRepository: UsersRepository, private readonly storageService: ManagedStorageService, private readonly imageProcessingService: ImageProcessingService, private readonly videoProcessingService: VideoProcessingService, private readonly feedVersionService: FeedVersionService, private readonly notificationsService: NotificationsService, private readonly auditService: AuditService, ) {} async create( userId: string, dto: CreatePostDto, 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) { throw new BadRequestException('Post can contain up to 10 images'); } if (imageFiles.length && inputImageUrls.length) { throw new BadRequestException('Provide either imageFiles or imageUrls, not both'); } if (videoFile && audioFile) { throw new BadRequestException('Post can contain either images, video, or audio'); } if (videoFile && dto.videoUrl) { throw new BadRequestException('Provide either videoFile or videoUrl, not both'); } 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'); } if ((audioFile || dto.audioUrl) && (imageFiles.length || inputImageUrls.length)) { throw new BadRequestException('Post can contain either images or audio, not both'); } const savedImageUploads = await this.saveImageFiles(imageFiles); 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 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 : []; const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || ''; const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || ''; const finalContent = dto.content?.trim() ?? ''; const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId); const collaboratorIds = await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator'); const imageItems = this.buildImageItems(finalImageUrls, dto.imageCaptions, dto.imageAltTexts); const mentionResolution = await this.resolveMentionTargets(dto.mentionUsernames, finalContent, userId); const { location, latitude, longitude } = this.normalizeLocation(dto); if (!finalContent && !finalImageUrls.length && !finalVideoUrl && !finalAudioUrl) { throw new BadRequestException('Post must contain caption or media'); } const postType = this.resolvePostType(finalImageUrls, finalVideoUrl, finalAudioUrl); const hashtags = this.extractHashtags(finalContent); const mediaMetadata = this.normalizeMediaMetadata(dto, postType, undefined, { audioSourceBuffer: audioFile?.buffer, waveformSeed: finalAudioUrl || finalContent || `${userId}:${Date.now()}`, thumbnailUrl: uploadedThumbnailUrl, }); let post: PostDocument; try { post = await this.postsRepository.create(userId, { content: finalContent, imageUrls: finalImageUrls, imageItems, imageVariants: finalImageVariants, videoUrl: finalVideoUrl, hlsUrl: uploadedHlsUrl, audioUrl: finalAudioUrl, thumbnailVariants: uploadedThumbnailVariants, taggedUserIds, collaboratorIds, mentionUsernames: mentionResolution.mentionUsernames, location, latitude, longitude, postType, visibility: dto.visibility ?? PostVisibility.PUBLIC, commentsDisabled: dto.commentsDisabled ?? false, commentsFollowersOnly: dto.commentsFollowersOnly ?? false, hashtags, ...mediaMetadata, }); } catch (error) { await Promise.all([ ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), 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); return post; } async update( userId: string, postId: string, dto: UpdatePostDto, 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) { throw new NotFoundException('Post not found'); } if (this.extractEntityId(post.authorId) !== userId) { throw new ForbiddenException('You can only update your own posts'); } const inputImageUrls = dto.imageUrls ?? []; if (inputImageUrls.length > 10 || imageFiles.length > 10) { throw new BadRequestException('Post can contain up to 10 images'); } if (imageFiles.length && inputImageUrls.length) { throw new BadRequestException('Provide either imageFiles or imageUrls, not both'); } if (videoFile && audioFile) { throw new BadRequestException('Post can contain either images, video, or audio'); } if (videoFile && dto.videoUrl) { throw new BadRequestException('Provide either videoFile or videoUrl, not both'); } 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'); } if ((audioFile || dto.audioUrl) && (imageFiles.length || inputImageUrls.length)) { throw new BadRequestException('Post can contain either images or audio, not both'); } const savedImageUploads = await this.saveImageFiles(imageFiles); 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 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) ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) : []; const existingThumbnailVariants = ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; const hasVideoUpdate = typeof dto.videoUrl !== 'undefined' || !!videoFile; const hasAudioUpdate = typeof dto.audioUrl !== 'undefined' || !!audioFile; const nextImageUrls = hasImageUpdate ? imageFiles.length ? uploadedImageUrls : inputImageUrls : post.imageUrls ?? []; const nextImageVariants = hasImageUpdate ? imageFiles.length ? uploadedImageVariants : [] : existingImageVariants; const nextVideoUrl = hasVideoUpdate ? videoFile ? uploadedVideoUrl : dto.videoUrl ?? '' : post.videoUrl ?? ''; const nextHlsUrl = hasVideoUpdate ? (videoFile ? uploadedHlsUrl : '') : post.hlsUrl ?? ''; const nextAudioUrl = hasAudioUpdate ? audioFile ? uploadedAudioUrl : dto.audioUrl ?? '' : post.audioUrl ?? ''; const nextThumbnailVariants = coverImageFile || videoFile ? uploadedThumbnailVariants : typeof dto.thumbnailUrl === 'string' ? null : existingThumbnailVariants; const nextPostType = this.resolvePostType(nextImageUrls, nextVideoUrl, nextAudioUrl); const nextContent = typeof dto.content === 'string' ? dto.content.trim() : post.content ?? ''; const nextTaggedUserIds = typeof dto.taggedUserIds !== 'undefined' ? await this.normalizeTaggedUserIds(dto.taggedUserIds, userId) : (post.taggedUserIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString())); const nextCollaboratorIds = typeof dto.collaboratorIds !== 'undefined' ? await this.normalizeUserIdList(dto.collaboratorIds, userId, 5, 'collaborator') : (post.collaboratorIds ?? []).map((id: Types.ObjectId | string) => new Types.ObjectId(id.toString())); const nextImageItems = this.buildImageItems( nextImageUrls, dto.imageCaptions, dto.imageAltTexts, hasImageUpdate ? [] : post.imageItems ?? [], ); const previousMentionUsernames = this.normalizeMentionUsernames(post.mentionUsernames ?? []); const shouldRecomputeMentions = typeof dto.content === 'string' || typeof dto.mentionUsernames !== 'undefined'; const mentionResolution = shouldRecomputeMentions ? await this.resolveMentionTargets(dto.mentionUsernames, nextContent, userId) : { mentionUsernames: previousMentionUsernames, mentionedUsers: [] as Array<{ id: string; username: string }>, }; const { location: nextLocation, latitude: nextLatitude, longitude: nextLongitude } = this.normalizeLocation(dto, { location: post.location ?? '', latitude: post.latitude ?? null, longitude: post.longitude ?? null, }); if (!nextContent && !nextImageUrls.length && !nextVideoUrl && !nextAudioUrl) { throw new BadRequestException('Post must contain caption or media'); } const mediaMetadata = this.normalizeMediaMetadata( dto, nextPostType, { durationSeconds: post.durationSeconds ?? null, thumbnailUrl: uploadedThumbnailUrl || (post.thumbnailUrl ?? ''), style: post.style ?? '', maqam: post.maqam ?? '', rhythmSignature: post.rhythmSignature ?? '', waveformPeaks: post.waveformPeaks ?? [], }, { audioSourceBuffer: audioFile?.buffer, waveformSeed: nextAudioUrl || nextContent || post.id, thumbnailUrl: uploadedThumbnailUrl, }, ); const payload: Record = { ...dto, content: nextContent, imageUrls: nextImageUrls, imageItems: nextImageItems, imageVariants: nextImageVariants, hlsUrl: nextHlsUrl, thumbnailVariants: nextThumbnailVariants, taggedUserIds: nextTaggedUserIds, collaboratorIds: nextCollaboratorIds, mentionUsernames: mentionResolution.mentionUsernames, location: nextLocation, latitude: nextLatitude, longitude: nextLongitude, postType: nextPostType, ...mediaMetadata, }; if (typeof dto.content === 'string') { payload.hashtags = this.extractHashtags(nextContent); } if (hasImageUpdate) { payload.hashtags = this.extractHashtags(nextContent); } if (hasVideoUpdate && !hasAudioUpdate) { payload.audioUrl = ''; } if (hasAudioUpdate && !hasVideoUpdate) { payload.videoUrl = ''; payload.hlsUrl = ''; } if (hasImageUpdate) { payload.videoUrl = ''; payload.audioUrl = ''; payload.hlsUrl = ''; payload.thumbnailVariants = null; } if (videoFile) { payload.videoUrl = uploadedVideoUrl; payload.hlsUrl = uploadedHlsUrl; payload.thumbnailVariants = uploadedThumbnailVariants; payload.imageUrls = []; payload.imageVariants = []; payload.audioUrl = ''; } if (audioFile) { payload.audioUrl = uploadedAudioUrl; payload.imageUrls = []; payload.imageVariants = []; payload.videoUrl = ''; payload.hlsUrl = ''; } if (nextPostType !== PostType.AUDIO && nextPostType !== PostType.VIDEO) { payload.thumbnailVariants = null; } let updated: PostDocument | null; try { updated = await this.postsRepository.updateById(postId, payload as any); } catch (error) { await Promise.all([ ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), 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 (!updated) { await Promise.all([ ...savedImageUploads.map((item) => this.deleteSavedImageAsset(item)), uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(), uploadedHlsUrl ? this.storageService.deleteContainingDirectory(uploadedHlsUrl) : Promise.resolve(), 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'); } if (hasVideoUpdate && (post.videoUrl ?? '') !== (updated.videoUrl ?? '')) { await this.deleteManagedPostMedia(post.videoUrl ?? ''); } if ((post.hlsUrl ?? '') !== (updated.hlsUrl ?? '')) { await this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''); } 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 ?? ''); } if (hasImageUpdate) { const nextImageSet = new Set(updated.imageUrls ?? []); const existingImageAssets = this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants); await Promise.all( existingImageAssets .filter((asset) => !nextImageSet.has(asset.primaryUrl)) .map((asset) => this.deleteSavedImageAsset(asset)), ); } await this.feedVersionService.bumpGlobalVersion(); if (shouldRecomputeMentions) { const previousMentionSet = new Set(previousMentionUsernames); const nextMentionedUsers = mentionResolution.mentionedUsers.filter( (mentionedUser) => !previousMentionSet.has(mentionedUser.username), ); await this.notifyMentionedUsers(userId, postId, nextMentionedUsers, nextContent); } return updated; } async remove(userId: string, postId: string): Promise { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } if (this.extractEntityId(post.authorId) !== userId) { throw new ForbiddenException('You can only delete your own posts'); } await this.postsRepository.deleteById(postId, userId); const existingImageVariants = Array.isArray((post as any).imageVariants) ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) : []; const existingThumbnailVariants = ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; await Promise.all([ ...this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants).map((asset) => this.deleteSavedImageAsset(asset), ), this.deleteManagedPostMedia(post.videoUrl ?? ''), this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''), this.deleteManagedPostMedia(post.audioUrl ?? ''), this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants), ]); await this.usersRepository.incrementPostsCount(userId, -1); await this.feedVersionService.bumpGlobalVersion(); } async findById(postId: string): Promise { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } return post; } async findUserPosts(userId: string, query: PostQueryDto) { if (!Types.ObjectId.isValid(userId)) { throw new BadRequestException('Invalid user id'); } const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; const filter: Record = { authorId: new Types.ObjectId(userId), isArchived: { $ne: true }, }; if (query.visibility) { filter.visibility = query.visibility; } if (query.postType) { filter.postType = query.postType; } if (query.q) { filter.content = { $regex: query.q.trim(), $options: 'i' }; } if (query.hashtag) { filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); } const direction = resolveMongoSortDirection(query.sortOrder); const sortField = query.sortBy ?? 'createdAt'; const sort = { pinnedToProfile: -1, [sortField]: direction } as Record; const [items, total] = await Promise.all([ this.postsRepository.findMany(filter, skip, limit, sort), this.postsRepository.count(filter), ]); return buildPaginatedResponse(items, { page, limit, total, offset: skip, }); } async findPlatformPosts(query: AdminPostQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; const filter: Record = {}; if (query.visibility) { filter.visibility = query.visibility; } if (query.postType) { filter.postType = query.postType; } if (query.authorId) { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q?.trim()) { filter.content = { $regex: query.q.trim(), $options: 'i' }; } if (query.hashtag?.trim()) { filter.hashtags = query.hashtag.trim().replace(/^#+/, '').toLowerCase(); } if (query.moderationStatus) { filter.moderationStatus = query.moderationStatus; } const direction = resolveMongoSortDirection(query.sortOrder); const sortField = query.sortBy ?? 'createdAt'; const sort = { [sortField]: direction } as Record; const [items, total] = await Promise.all([ this.postsRepository.findManyAdmin(filter, skip, limit, sort), this.postsRepository.countAdmin(filter), ]); return buildPaginatedResponse(items, { page, limit, total, offset: skip, }); } async createReel( 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'); } if (videoFile && dto.videoUrl) { throw new BadRequestException('Provide either videoFile or videoUrl for reel, not both'); } return this.create( userId, { content: dto.content ?? '', videoUrl: dto.videoUrl, durationSeconds: dto.durationSeconds, thumbnailUrl: dto.thumbnailUrl, style: dto.style, maqam: dto.maqam, rhythmSignature: dto.rhythmSignature, mentionUsernames: dto.mentionUsernames, visibility: dto.visibility ?? PostVisibility.PUBLIC, }, [], videoFile, undefined, coverImageFile, ); } async findReels(query: ReelQueryDto) { const page = query.page ?? 1; const limit = query.limit ?? 20; const skip = (page - 1) * limit; const filter: Record = { postType: PostType.VIDEO }; if (query.visibility) { filter.visibility = query.visibility; } if (query.authorId) { filter.authorId = new Types.ObjectId(query.authorId); } if (query.q) { filter.content = { $regex: query.q.trim(), $options: 'i' }; } const direction = resolveMongoSortDirection(query.sortOrder); const sortField = query.sortBy ?? 'createdAt'; const sort = { [sortField]: direction } as Record; const [items, total] = await Promise.all([ this.postsRepository.findMany(filter, skip, limit, sort), this.postsRepository.count(filter), ]); return buildPaginatedResponse(items, { page, limit, total, offset: skip, }); } async registerView(userId: string, postId: string): Promise<{ success: true; postId: string; viewCount: number }> { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } await this.postsRepository.incrementViewCount(postId, 1); return { success: true, postId, viewCount: (post.viewCount ?? 0) + 1, }; } async registerPlay( userId: string, postId: string, ): Promise<{ success: true; postId: string; playCount: number }> { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } if (post.postType !== PostType.AUDIO && post.postType !== PostType.VIDEO) { throw new BadRequestException('play counter is available only for audio or video posts'); } await this.postsRepository.incrementPlayCount(postId, 1); return { success: true, postId, playCount: (post.playCount ?? 0) + 1, }; } async registerShare( userId: string, postId: string, ): Promise<{ success: true; postId: string; shareCount: number }> { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } await this.postsRepository.incrementShareCount(postId, 1); await this.feedVersionService.bumpGlobalVersion(); const authorId = this.extractEntityId(post.authorId); if (authorId && authorId !== userId) { try { await this.notificationsService.createShareNotification(userId, authorId, postId, { resourceType: 'post', previewText: (post.content ?? '').slice(0, 140), }); } catch (error) { this.logger.warn( `Share notification failed for actor=${userId} recipient=${authorId}: ${ error instanceof Error ? error.message : 'unknown error' }`, ); } } return { success: true, postId, shareCount: (post.shareCount ?? 0) + 1, }; } async updateCommentSettings( userId: string, postId: string, dto: UpdateCommentSettingsDto, ): Promise { await this.assertPostOwner(userId, postId); const updated = await this.postsRepository.updateById(postId, { ...(typeof dto.commentsDisabled === 'boolean' ? { commentsDisabled: dto.commentsDisabled } : {}), ...(typeof dto.commentsFollowersOnly === 'boolean' ? { commentsFollowersOnly: dto.commentsFollowersOnly } : {}), ...(Array.isArray(dto.commentFilterKeywords) ? { commentFilterKeywords: Array.from( new Set(dto.commentFilterKeywords.map((item) => item.trim().toLowerCase()).filter(Boolean)), ).slice(0, 50), } : {}), }); if (!updated) { throw new NotFoundException('Post not found'); } return updated; } async pinToProfile(userId: string, postId: string): Promise { await this.assertPostOwner(userId, postId); const updated = await this.postsRepository.updateById(postId, { pinnedToProfile: true }); if (!updated) { throw new NotFoundException('Post not found'); } return updated; } async unpinFromProfile(userId: string, postId: string): Promise { await this.assertPostOwner(userId, postId); const updated = await this.postsRepository.updateById(postId, { pinnedToProfile: false }); if (!updated) { throw new NotFoundException('Post not found'); } return updated; } async archive(userId: string, postId: string): Promise { await this.assertPostOwner(userId, postId); const updated = await this.postsRepository.updateById(postId, { isArchived: true, pinnedToProfile: false }); if (!updated) { throw new NotFoundException('Post not found'); } await this.feedVersionService.bumpGlobalVersion(); return updated; } async restoreArchived(userId: string, postId: string): Promise { await this.assertPostOwner(userId, postId); const updated = await this.postsRepository.updateById(postId, { isArchived: false }); if (!updated) { throw new NotFoundException('Post not found'); } await this.feedVersionService.bumpGlobalVersion(); return updated; } async removeBySuperAdmin(superAdminIdentifier: string, postId: string): Promise { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } const existingImageVariants = Array.isArray((post as any).imageVariants) ? (((post as any).imageVariants ?? []) as PostMediaVariantSet[]) : []; const existingThumbnailVariants = ((post as any).thumbnailVariants as PostMediaVariantSet | null | undefined) ?? null; await this.postsRepository.deleteById(postId, superAdminIdentifier); await Promise.all([ ...this.buildSavedImageAssets(post.imageUrls ?? [], existingImageVariants).map((asset) => this.deleteSavedImageAsset(asset), ), this.deleteManagedPostMedia(post.videoUrl ?? ''), this.storageService.deleteContainingDirectory(post.hlsUrl ?? ''), this.deleteManagedPostMedia(post.audioUrl ?? ''), this.deleteThumbnailAsset(post.thumbnailUrl ?? '', existingThumbnailVariants), ]); const authorId = this.extractEntityId(post.authorId); if (authorId) { await this.usersRepository.incrementPostsCount(authorId, -1); } await this.feedVersionService.bumpGlobalVersion(); await this.auditService.logSuperAdminAction( superAdminIdentifier, 'post_delete', 'post', postId, { authorId }, ); } async updateModerationStatusBySuperAdmin( superAdminIdentifier: string, postId: string, dto: { status: ModerationStatus; reason?: string }, ): Promise { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } const updated = await this.postsRepository.updateModerationStatus(postId, { moderationStatus: dto.status, moderationReason: dto.reason?.trim() ?? '', }); if (!updated) { throw new NotFoundException('Post not found'); } await this.feedVersionService.bumpGlobalVersion(); await this.auditService.logSuperAdminAction( superAdminIdentifier, 'post_moderation_status_update', 'post', postId, { previousStatus: post.moderationStatus ?? ModerationStatus.ACTIVE, nextStatus: dto.status, reason: dto.reason?.trim() ?? '', }, ); return updated; } private resolvePostType(imageUrls: string[] = [], videoUrl?: string, audioUrl?: string): PostType { const hasImages = imageUrls.length > 0; const hasVideo = !!videoUrl?.trim(); const hasAudio = !!audioUrl?.trim(); if ((hasImages && hasVideo) || (hasImages && hasAudio) || (hasVideo && hasAudio)) { throw new BadRequestException('Post can contain either images, video, or audio'); } if (hasImages) { return PostType.IMAGE; } if (hasVideo) { return PostType.VIDEO; } if (hasAudio) { return PostType.AUDIO; } return PostType.TEXT; } private normalizeMediaMetadata( dto: PostMediaMetadataInput, postType: PostType, fallback: NormalizedPostMediaMetadata = { durationSeconds: null, thumbnailUrl: '', style: '', maqam: '', rhythmSignature: '', waveformPeaks: [], }, options: { audioSourceBuffer?: Buffer; waveformSeed?: string; thumbnailUrl?: string; } = {}, ): NormalizedPostMediaMetadata { const supportsMediaMetadata = postType === PostType.AUDIO || postType === PostType.VIDEO; const supportsWaveform = postType === PostType.AUDIO; if (!supportsMediaMetadata) { if (this.hasMediaMetadataInput(dto)) { throw new BadRequestException('Audio/video metadata is allowed only for audio or video posts'); } return { durationSeconds: null, thumbnailUrl: '', style: '', maqam: '', rhythmSignature: '', waveformPeaks: [], }; } if (!supportsWaveform && typeof dto.waveformPeaks !== 'undefined') { throw new BadRequestException('waveformPeaks is allowed only for audio posts'); } return { durationSeconds: typeof dto.durationSeconds === 'number' ? dto.durationSeconds : fallback.durationSeconds, thumbnailUrl: typeof dto.thumbnailUrl === 'string' ? dto.thumbnailUrl.trim() : options.thumbnailUrl || fallback.thumbnailUrl, style: typeof dto.style === 'string' ? dto.style.trim() : fallback.style, maqam: typeof dto.maqam === 'string' ? dto.maqam.trim() : fallback.maqam, rhythmSignature: 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, ) : [], }; } private hasMediaMetadataInput(dto: PostMediaMetadataInput): boolean { return ( typeof dto.durationSeconds === 'number' || typeof dto.thumbnailUrl === 'string' || typeof dto.style === 'string' || typeof dto.maqam === 'string' || typeof dto.rhythmSignature === 'string' || typeof dto.waveformPeaks !== 'undefined' ); } private extractMentions(content: string): string[] { const matches = content.match(/@[\p{L}\p{N}_.]+/gu) ?? []; const normalized = matches .map((item) => item.replace('@', '').trim().toLowerCase()) .filter(Boolean); return Array.from(new Set(normalized)).slice(0, 30); } private normalizeMentionUsernames(input: string[] = []): string[] { return Array.from( new Set( input .map((username) => username?.trim().replace(/^@+/, '').toLowerCase()) .filter((username): username is string => !!username), ), ); } private async resolveMentionTargets( explicitMentionUsernames: string[] | undefined, content: string, authorId: string, ): Promise<{ mentionUsernames: string[]; mentionedUsers: Array<{ id: string; username: string }>; }> { const mergedMentionUsernames = Array.from( new Set([ ...this.extractMentions(content), ...this.normalizeMentionUsernames(explicitMentionUsernames ?? []), ]), ); if (mergedMentionUsernames.length > 30) { throw new BadRequestException('You can mention up to 30 users only'); } if (!mergedMentionUsernames.length) { return { mentionUsernames: [], mentionedUsers: [] }; } const users = await this.usersRepository.findByUsernames(mergedMentionUsernames); const userByUsername = new Map( users.map((user) => [user.username.toLowerCase(), { id: user.id, username: user.username.toLowerCase() }]), ); const mentionedUsers = mergedMentionUsernames .map((username) => userByUsername.get(username)) .filter((user): user is { id: string; username: string } => !!user) .filter((user) => user.id !== authorId); return { mentionUsernames: mentionedUsers.map((user) => user.username), mentionedUsers, }; } private extractHashtags(content: string): string[] { const matches = content.match(/#[\p{L}\p{N}_]+/gu) ?? []; const normalized = matches .map((item) => item.replace('#', '').trim().toLowerCase()) .filter(Boolean); return Array.from(new Set(normalized)).slice(0, 20); } private normalizeLocation( dto: Pick, fallback: { location: string; latitude: number | null; longitude: number | null } = { location: '', latitude: null, longitude: null, }, ): { location: string; latitude: number | null; longitude: number | null } { const location = typeof dto.location === 'string' ? dto.location.trim() : fallback.location; const latitude = typeof dto.latitude === 'number' ? dto.latitude : fallback.latitude; const longitude = typeof dto.longitude === 'number' ? dto.longitude : fallback.longitude; const hasLatitude = typeof latitude === 'number'; const hasLongitude = typeof longitude === 'number'; if (hasLatitude !== hasLongitude) { throw new BadRequestException('latitude and longitude must be provided together'); } return { location, latitude, longitude }; } private buildImageItems( imageUrls: string[], captions: string[] | undefined, altTexts: string[] | undefined, fallback: PostImageItem[] = [], ): PostImageItem[] { return imageUrls.map((url, index) => ({ url, caption: typeof captions?.[index] === 'string' ? captions[index].trim() : fallback[index]?.caption ?? '', altText: typeof altTexts?.[index] === 'string' ? altTexts[index].trim() : fallback[index]?.altText ?? '', order: index, })); } private async normalizeTaggedUserIds( input: string[] | undefined, authorId: string, ): Promise { if (!input?.length) { return []; } const unique = Array.from( new Set( input .map((item) => item?.trim()) .filter((item): item is string => !!item) .filter((item) => item !== authorId), ), ); if (unique.length > 20) { throw new BadRequestException('You can tag up to 20 users only'); } if (unique.some((id) => !Types.ObjectId.isValid(id))) { throw new BadRequestException('Invalid tagged user id'); } const rows = await this.usersRepository.findMany( { _id: { $in: unique.map((id) => new Types.ObjectId(id)) }, }, 0, unique.length, ); if (rows.length !== unique.length) { throw new BadRequestException('One or more tagged users do not exist'); } return unique.map((id) => new Types.ObjectId(id)); } private async normalizeUserIdList( input: string[] | undefined, currentUserId: string, maxCount: number, label: string, ): Promise { if (!input?.length) { return []; } const unique = Array.from( new Set( input .map((item) => item?.trim()) .filter((item): item is string => !!item) .filter((item) => item !== currentUserId), ), ); if (unique.length > maxCount) { throw new BadRequestException(`You can add up to ${maxCount} ${label}s only`); } if (unique.some((id) => !Types.ObjectId.isValid(id))) { throw new BadRequestException(`Invalid ${label} user id`); } const rows = await this.usersRepository.findMany( { _id: { $in: unique.map((id) => new Types.ObjectId(id)) } }, 0, unique.length, ); if (rows.length !== unique.length) { throw new BadRequestException(`One or more ${label}s do not exist`); } return unique.map((id) => new Types.ObjectId(id)); } private async notifyMentionedUsers( actorId: string, postId: string, mentionedUsers: Array<{ id: string; username: string }>, content: string, ): Promise { if (!mentionedUsers.length) { return; } await Promise.all( mentionedUsers.map(async (mentionedUser) => { try { await this.notificationsService.createMentionNotification(actorId, mentionedUser.id, postId, { resourceType: 'post', previewText: content.slice(0, 140), }); } catch (error) { this.logger.warn( `Mention notification failed for actor=${actorId} recipient=${mentionedUser.id}: ${ error instanceof Error ? error.message : 'unknown error' }`, ); } }), ); } private async saveVideoUpload(file: UploadedVideoFile): Promise { this.validateMediaFile('video', file); const optimized = await this.videoProcessingService.optimizeForPlayback(file); const extension = this.validateMediaFile('video', optimized.file); let videoUrl = ''; let hlsUrl = ''; let thumbnailUrl = ''; let thumbnailVariants: PostMediaVariantSet | null = null; const hlsFolderName = `stream-${new Types.ObjectId().toString()}`; try { videoUrl = await this.storageService.saveFile({ folderSegments: ['posts', 'videos'], extension, buffer: optimized.file.buffer, contentType: optimized.file.mimetype, fileNamePrefix: 'video', }); if (optimized.generatedHls?.files.length) { const savedHlsFiles = await this.storageService.saveFiles({ folderSegments: ['posts', 'hls', hlsFolderName], files: optimized.generatedHls.files, }); hlsUrl = savedHlsFiles[optimized.generatedHls.playlistRelativePath] ?? ''; } if (optimized.generatedThumbnail) { const savedThumbnail = await this.saveResponsiveImageAsset('thumbnails', { buffer: optimized.generatedThumbnail.buffer, size: optimized.generatedThumbnail.buffer.length, mimetype: optimized.generatedThumbnail.contentType, originalname: `thumbnail${optimized.generatedThumbnail.extension}`, }); thumbnailUrl = savedThumbnail.primaryUrl; thumbnailVariants = savedThumbnail.variants; } return { videoUrl, hlsUrl, thumbnailUrl, thumbnailVariants }; } catch (error) { await Promise.all([ videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(), hlsUrl ? this.storageService.deleteContainingDirectory(hlsUrl) : Promise.resolve(), thumbnailUrl ? this.deleteThumbnailAsset(thumbnailUrl, thumbnailVariants) : Promise.resolve(), ]); throw error; } } async createRepost(userId: string, sourcePostId: string, dto: CreateRepostDto): Promise { const sourcePost = await this.postsRepository.findById(sourcePostId); if (!sourcePost) { throw new NotFoundException('Source post not found'); } if (this.extractEntityId(sourcePost.authorId) === userId && !dto.content?.trim()) { throw new BadRequestException('You cannot repost your own post without a quote'); } const content = dto.content?.trim() ?? ''; const mentionResolution = await this.resolveMentionTargets(undefined, content, userId); const hashtags = this.extractHashtags(content); const post = await this.postsRepository.create(userId, { content, postType: PostType.TEXT, visibility: dto.visibility ?? PostVisibility.PUBLIC, repostOfPostId: content ? null : new Types.ObjectId(sourcePostId), quoteOfPostId: content ? new Types.ObjectId(sourcePostId) : null, mentionUsernames: mentionResolution.mentionUsernames, hashtags, }); await this.usersRepository.incrementPostsCount(userId, 1); await this.postsRepository.incrementShareCount(sourcePostId, 1); await this.feedVersionService.bumpGlobalVersion(); await this.notifyMentionedUsers(userId, post.id, mentionResolution.mentionedUsers, content); const populated = await this.postsRepository.findById(post.id); return populated ?? post; } private async saveMediaFile( mediaType: 'image' | 'video' | 'audio', file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, ): Promise { const extension = this.validateMediaFile(mediaType, file); const folder = mediaType === 'image' ? 'images' : mediaType === 'video' ? 'videos' : 'audios'; return this.storageService.saveFile({ folderSegments: ['posts', folder], extension, buffer: file.buffer, contentType: file.mimetype, fileNamePrefix: mediaType, }); } private validateMediaFile( mediaType: 'image' | 'video' | 'audio', file: { mimetype?: string; size: number; buffer: Buffer; originalname?: string }, ): string { const extension = this.resolveMediaExtension(mediaType, file); if (!extension) { throw new BadRequestException( mediaType === 'image' ? 'imageFiles must be jpg, jpeg, png, webp, or gif' : mediaType === 'video' ? 'videoFile must be mp4, mov, webm, mkv, or avi' : 'audioFile must be mp3, wav, m4a, aac, ogg, or webm', ); } const maxSize = mediaType === 'image' ? 10 * 1024 * 1024 : mediaType === 'video' ? 100 * 1024 * 1024 : 20 * 1024 * 1024; if (file.size > maxSize) { throw new BadRequestException( mediaType === 'image' ? 'Each image must be 10MB or less' : mediaType === 'video' ? 'videoFile size must be 100MB or less' : 'audioFile size must be 20MB or less', ); } return extension; } private resolveMediaExtension( mediaType: 'image' | 'video' | 'audio', file: { mimetype?: string; originalname?: string }, ): string | null { const originalExtension = extname(file.originalname ?? '').toLowerCase(); const imageAllowed = new Set(['.jpg', '.jpeg', '.png', '.webp', '.gif']); const videoAllowed = new Set(['.mp4', '.mov', '.webm', '.mkv', '.avi']); const audioAllowed = new Set(['.mp3', '.wav', '.m4a', '.aac', '.ogg', '.webm']); const allowed = mediaType === 'image' ? imageAllowed : mediaType === 'video' ? videoAllowed : audioAllowed; if (allowed.has(originalExtension)) { return originalExtension; } if (mediaType === 'image') { switch (file.mimetype) { case 'image/jpeg': return '.jpg'; case 'image/png': return '.png'; case 'image/webp': return '.webp'; case 'image/gif': return '.gif'; default: return null; } } if (mediaType === 'video') { switch (file.mimetype) { case 'video/mp4': return '.mp4'; case 'video/quicktime': return '.mov'; case 'video/webm': return '.webm'; case 'video/x-matroska': return '.mkv'; case 'video/x-msvideo': return '.avi'; default: return null; } } switch (file.mimetype) { case 'audio/mpeg': return '.mp3'; case 'audio/wav': case 'audio/x-wav': return '.wav'; case 'audio/mp4': case 'audio/x-m4a': return '.m4a'; case 'audio/aac': return '.aac'; case 'audio/ogg': return '.ogg'; case 'audio/webm': return '.webm'; default: return null; } } private async saveImageFiles( files: Array<{ mimetype?: string; size: number; buffer: Buffer; originalname?: string }>, ): Promise { if (!files.length) { return []; } if (files.length > 10) { throw new BadRequestException('Post can contain up to 10 images'); } const uploads: SavedImageUpload[] = []; try { for (const file of files) { uploads.push(await this.saveResponsiveImageAsset('images', file)); } return uploads; } catch (error) { await Promise.all(uploads.map((upload) => this.deleteSavedImageAsset(upload))); throw error; } } private async saveResponsiveImageAsset( folder: 'images' | 'thumbnails', file: UploadedImageFile, ): Promise { this.validateMediaFile('image', file); const processed = await this.imageProcessingService.processForResponsiveDelivery(file); const groupName = `${folder.slice(0, -1)}-${new Types.ObjectId().toString()}`; const savedFiles = await this.storageService.saveFiles({ folderSegments: ['posts', folder, groupName], files: processed.variants.map((variant) => ({ relativePath: variant.relativePath, buffer: variant.buffer, contentType: variant.contentType, })), }); const variants = this.buildVariantSet(processed, savedFiles); return { primaryUrl: this.resolvePrimaryVariantUrl(variants, processed.primaryVariantName), variants, }; } private buildVariantSet( processed: Awaited>, savedFiles: Record, ): PostMediaVariantSet { const byName = new Map( processed.variants.map((variant) => [variant.name, savedFiles[variant.relativePath] ?? '']), ); const originalUrl = byName.get('original') ?? ''; const lowUrl = byName.get('low') ?? originalUrl; const mediumUrl = byName.get('medium') ?? byName.get('high') ?? lowUrl; const highUrl = byName.get('high') ?? mediumUrl ?? lowUrl; return { originalUrl, lowUrl, mediumUrl, highUrl, }; } private resolvePrimaryVariantUrl( variants: PostMediaVariantSet, preferredVariantName: 'original' | 'low' | 'medium' | 'high', ): string { const candidates = preferredVariantName === 'low' ? [variants.lowUrl, variants.mediumUrl, variants.highUrl, variants.originalUrl] : preferredVariantName === 'high' ? [variants.highUrl, variants.mediumUrl, variants.lowUrl, variants.originalUrl] : preferredVariantName === 'original' ? [variants.originalUrl, variants.highUrl, variants.mediumUrl, variants.lowUrl] : [variants.mediumUrl, variants.highUrl, variants.lowUrl, variants.originalUrl]; return candidates.find((candidate) => !!candidate) ?? ''; } private buildSavedImageAssets( imageUrls: string[], imageVariants: PostMediaVariantSet[] = [], ): SavedImageUpload[] { return imageUrls.map((primaryUrl, index) => ({ primaryUrl, variants: imageVariants[index] ?? this.buildFlatVariantSet(primaryUrl), })); } private buildFlatVariantSet(primaryUrl: string): PostMediaVariantSet { return { originalUrl: primaryUrl, lowUrl: primaryUrl, mediumUrl: primaryUrl, highUrl: primaryUrl, }; } private async deleteSavedImageAsset(upload: SavedImageUpload): Promise { const anchorUrl = upload.variants.mediumUrl || upload.variants.highUrl || upload.variants.lowUrl || upload.variants.originalUrl || upload.primaryUrl; const hasManagedVariantGroup = [ upload.variants.originalUrl, upload.variants.lowUrl, upload.variants.mediumUrl, upload.variants.highUrl, ].some((url) => !!url && url !== upload.primaryUrl); if (anchorUrl && hasManagedVariantGroup) { await this.storageService.deleteContainingDirectory(anchorUrl); return; } await this.deleteManagedPostMedia(upload.primaryUrl); } private async deleteThumbnailAsset( thumbnailUrl: string, thumbnailVariants: PostMediaVariantSet | null, ): Promise { if (!thumbnailUrl) { return; } const anchorUrl = thumbnailVariants?.mediumUrl || thumbnailVariants?.highUrl || thumbnailVariants?.lowUrl || thumbnailVariants?.originalUrl || ''; const hasManagedVariantGroup = !!thumbnailVariants && [ thumbnailVariants.originalUrl, thumbnailVariants.lowUrl, thumbnailVariants.mediumUrl, thumbnailVariants.highUrl, ].some((url) => !!url && url !== thumbnailUrl); if (anchorUrl && hasManagedVariantGroup) { await this.storageService.deleteContainingDirectory(anchorUrl); return; } await this.deleteManagedPostMedia(thumbnailUrl); } private async deleteManagedPostMedia(fileUrl: string): Promise { await this.storageService.deleteFile(fileUrl); } private resolveAudioWaveformPeaks( providedPeaks: number[] | undefined, sourceBuffer: Buffer | undefined, waveformSeed: string | undefined, fallbackPeaks: number[] = [], ): number[] { if (Array.isArray(providedPeaks) && providedPeaks.length) { return normalizeWaveformPeaks(providedPeaks); } if (sourceBuffer?.length) { return generateWaveformPeaksFromBuffer(sourceBuffer); } if (fallbackPeaks.length) { return normalizeWaveformPeaks(fallbackPeaks); } return generateWaveformPeaksFromSeed(waveformSeed ?? 'audio-post'); } private async assertPostOwner(userId: string, postId: string): Promise { const post = await this.postsRepository.findById(postId); if (!post) { throw new NotFoundException('Post not found'); } if (this.extractEntityId(post.authorId) !== userId) { throw new ForbiddenException('You can update only your own posts'); } return post; } 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 ''; } }