الملفات
back_end_oudelaa/src/modules/posts/posts.service.ts

1145 أسطر
38 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 { 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 { PostQueryDto } from './dto/post-query.dto';
import { ReelQueryDto } from './dto/reel-query.dto';
import { UpdatePostDto } from './dto/update-post.dto';
import { PostDocument } 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;
thumbnailUrl: string;
};
@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 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 },
): 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 ((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 uploadedImageUrls = await this.saveImageFiles(imageFiles);
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const finalImageUrls = uploadedImageUrls.length ? uploadedImageUrls : inputImageUrls;
const finalVideoUrl = uploadedVideoUrl || dto.videoUrl || '';
const finalAudioUrl = uploadedAudioUrl || dto.audioUrl || '';
const finalContent = dto.content?.trim() ?? '';
const taggedUserIds = await this.normalizeTaggedUserIds(dto.taggedUserIds, userId);
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,
videoUrl: finalVideoUrl,
audioUrl: finalAudioUrl,
taggedUserIds,
mentionUsernames: mentionResolution.mentionUsernames,
location,
latitude,
longitude,
postType,
visibility: dto.visibility ?? PostVisibility.PUBLIC,
hashtags,
...mediaMetadata,
});
} catch (error) {
await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]);
throw error;
}
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 },
): 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 ((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 uploadedImageUrls = await this.saveImageFiles(imageFiles);
const savedVideoUpload = videoFile ? await this.saveVideoUpload(videoFile) : null;
const uploadedVideoUrl = savedVideoUpload?.videoUrl ?? '';
const uploadedThumbnailUrl = savedVideoUpload?.thumbnailUrl ?? '';
const uploadedAudioUrl = audioFile ? await this.saveMediaFile('audio', audioFile) : '';
const hasImageUpdate = typeof dto.imageUrls !== 'undefined' || imageFiles.length > 0;
const hasVideoUpdate = typeof dto.videoUrl !== 'undefined' || !!videoFile;
const hasAudioUpdate = typeof dto.audioUrl !== 'undefined' || !!audioFile;
const nextImageUrls = hasImageUpdate
? imageFiles.length
? uploadedImageUrls
: inputImageUrls
: post.imageUrls ?? [];
const nextVideoUrl = hasVideoUpdate
? videoFile
? uploadedVideoUrl
: dto.videoUrl ?? ''
: post.videoUrl ?? '';
const nextAudioUrl = hasAudioUpdate
? audioFile
? uploadedAudioUrl
: dto.audioUrl ?? ''
: post.audioUrl ?? '';
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 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,
taggedUserIds: nextTaggedUserIds,
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 = '';
}
if (hasImageUpdate) {
payload.videoUrl = '';
payload.audioUrl = '';
}
if (videoFile) {
payload.videoUrl = uploadedVideoUrl;
payload.imageUrls = [];
payload.audioUrl = '';
}
if (audioFile) {
payload.audioUrl = uploadedAudioUrl;
payload.imageUrls = [];
payload.videoUrl = '';
}
let updated: PostDocument | null;
try {
updated = await this.postsRepository.updateById(postId, payload as any);
} catch (error) {
await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : Promise.resolve(),
uploadedAudioUrl ? this.deleteManagedPostMedia(uploadedAudioUrl) : Promise.resolve(),
]);
throw error;
}
if (!updated) {
await Promise.all([
...uploadedImageUrls.map((url) => this.deleteManagedPostMedia(url)),
uploadedVideoUrl ? this.deleteManagedPostMedia(uploadedVideoUrl) : Promise.resolve(),
uploadedThumbnailUrl ? this.deleteManagedPostMedia(uploadedThumbnailUrl) : 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.thumbnailUrl ?? '') !== (updated.thumbnailUrl ?? '')) {
await this.deleteManagedPostMedia(post.thumbnailUrl ?? '');
}
if (hasAudioUpdate && (post.audioUrl ?? '') !== (updated.audioUrl ?? '')) {
await this.deleteManagedPostMedia(post.audioUrl ?? '');
}
if (hasImageUpdate) {
const nextImageSet = new Set(updated.imageUrls ?? []);
await Promise.all(
(post.imageUrls ?? [])
.filter((url) => !nextImageSet.has(url))
.map((url) => this.deleteManagedPostMedia(url)),
);
}
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);
await Promise.all([
...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)),
this.deleteManagedPostMedia(post.videoUrl ?? ''),
this.deleteManagedPostMedia(post.audioUrl ?? ''),
]);
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) };
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 = { [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 },
): 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,
);
}
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 removeBySuperAdmin(superAdminIdentifier: string, postId: string): Promise<void> {
const post = await this.postsRepository.findById(postId);
if (!post) {
throw new NotFoundException('Post not found');
}
await this.postsRepository.deleteById(postId, superAdminIdentifier);
await Promise.all([
...(post.imageUrls ?? []).map((url) => this.deleteManagedPostMedia(url)),
this.deleteManagedPostMedia(post.videoUrl ?? ''),
this.deleteManagedPostMedia(post.audioUrl ?? ''),
]);
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 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 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 thumbnailUrl = '';
try {
videoUrl = await this.storageService.saveFile({
folderSegments: ['posts', 'videos'],
extension,
buffer: optimized.file.buffer,
contentType: optimized.file.mimetype,
fileNamePrefix: 'video',
});
if (optimized.generatedThumbnail) {
thumbnailUrl = await this.storageService.saveFile({
folderSegments: ['posts', 'thumbnails'],
extension: optimized.generatedThumbnail.extension,
buffer: optimized.generatedThumbnail.buffer,
contentType: optimized.generatedThumbnail.contentType,
fileNamePrefix: 'thumbnail',
});
}
return { videoUrl, thumbnailUrl };
} catch (error) {
await Promise.all([
videoUrl ? this.deleteManagedPostMedia(videoUrl) : Promise.resolve(),
thumbnailUrl ? this.deleteManagedPostMedia(thumbnailUrl) : Promise.resolve(),
]);
throw error;
}
}
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<string[]> {
if (!files.length) {
return [];
}
if (files.length > 10) {
throw new BadRequestException('Post can contain up to 10 images');
}
const urls: string[] = [];
try {
for (const file of files) {
urls.push(await this.saveMediaFile('image', file));
}
return urls;
} catch (error) {
await Promise.all(urls.map((url) => this.deleteManagedPostMedia(url)));
throw error;
}
}
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 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 '';
}
}