1602 أسطر
56 KiB
TypeScript
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 '';
|
|
}
|
|
}
|