Add Instagram-style social features and Postman collections

هذا الالتزام موجود في:
boutmoun123
2026-05-24 15:21:03 +03:00
الأصل fdc40192f7
التزام 367fce6557
56 ملفات معدلة مع 20266 إضافات و5965 حذوفات

عرض الملف

@@ -27,10 +27,12 @@ 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, PostMediaVariantSet } from './schemas/post.schema';
import { PostDocument, PostImageItem, PostMediaVariantSet } from './schemas/post.schema';
import { PostsRepository } from './posts.repository';
type PostMediaMetadataInput = Pick<
@@ -124,6 +126,8 @@ export class PostsService {
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) {
@@ -143,18 +147,22 @@ export class PostsService {
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,
});
@@ -269,6 +277,16 @@ export class PostsService {
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';
@@ -309,10 +327,12 @@ export class PostsService {
...dto,
content: nextContent,
imageUrls: nextImageUrls,
imageItems: nextImageItems,
imageVariants: nextImageVariants,
hlsUrl: nextHlsUrl,
thumbnailVariants: nextThumbnailVariants,
taggedUserIds: nextTaggedUserIds,
collaboratorIds: nextCollaboratorIds,
mentionUsernames: mentionResolution.mentionUsernames,
location: nextLocation,
latitude: nextLatitude,
@@ -468,7 +488,10 @@ export class PostsService {
const limit = query.limit ?? 20;
const skip = (page - 1) * limit;
const filter: Record<string, unknown> = { authorId: new Types.ObjectId(userId) };
const filter: Record<string, unknown> = {
authorId: new Types.ObjectId(userId),
isArchived: { $ne: true },
};
if (query.visibility) {
filter.visibility = query.visibility;
}
@@ -483,7 +506,7 @@ export class PostsService {
}
const direction = resolveMongoSortDirection(query.sortOrder);
const sortField = query.sortBy ?? 'createdAt';
const sort = { [sortField]: direction } as Record<string, 1 | -1>;
const sort = { pinnedToProfile: -1, [sortField]: direction } as Record<string, 1 | -1>;
const [items, total] = await Promise.all([
this.postsRepository.findMany(filter, skip, limit, sort),
@@ -676,6 +699,69 @@ export class PostsService {
};
}
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) {
@@ -929,6 +1015,20 @@ export class PostsService {
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,
@@ -969,6 +1069,46 @@ export class PostsService {
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,
@@ -1047,6 +1187,37 @@ export class PostsService {
}
}
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 },
@@ -1343,6 +1514,17 @@ export class PostsService {
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 '';