الملفات
back_end_oudelaa/src/modules/posts/posts.service.ts
boutmoun123 efd87659b2
فشلت بعض الفحوصات
Deploy To Ghaymah / deploy (push) Has been cancelled
Support uploaded cover images for media posts
2026-05-25 21:42:27 +03:00

1602 أسطر
56 KiB
TypeScript

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<PostDocument> {
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<PostDocument> {
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<string, unknown> = {
...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<void> {
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<PostDocument> {
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<string, unknown> = {
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<string, 1 | -1>;
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<string, unknown> = {};
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<string, 1 | -1>;
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<PostDocument> {
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<string, unknown> = { 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<string, 1 | -1>;
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<PostDocument> {
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<PostDocument> {
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<PostDocument> {
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<PostDocument> {
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<PostDocument> {
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<void> {
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<PostDocument> {
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<CreatePostDto, 'location' | 'latitude' | 'longitude'>,
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<Types.ObjectId[]> {
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<Types.ObjectId[]> {
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<void> {
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<SavedVideoUpload> {
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<PostDocument> {
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<string> {
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<SavedImageUpload[]> {
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<SavedImageUpload> {
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<ReturnType<ImageProcessingService['processForResponsiveDelivery']>>,
savedFiles: Record<string, string>,
): 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<void> {
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<void> {
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<void> {
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<PostDocument> {
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 '';
}
}