Add Instagram-style social features and Postman collections
هذا الالتزام موجود في:
@@ -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 '';
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم