From 0166393c5a04084cad39ebd5793304978769492e Mon Sep 17 00:00:00 2001 From: YousefAref72 Date: Wed, 3 Dec 2025 15:22:14 +0200 Subject: [PATCH 1/2] handle mentions atomicly --- src/post/dto/create-post.dto.ts | 20 ++++- src/post/services/post.service.ts | 142 ++++++++++++++++++------------ 2 files changed, 103 insertions(+), 59 deletions(-) diff --git a/src/post/dto/create-post.dto.ts b/src/post/dto/create-post.dto.ts index f16fbe7..edfbbdb 100644 --- a/src/post/dto/create-post.dto.ts +++ b/src/post/dto/create-post.dto.ts @@ -1,5 +1,5 @@ import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; -import { IsEnum, IsNotEmpty, IsOptional, IsString, MaxLength } from 'class-validator'; +import { IsArray, IsEnum, IsNotEmpty, IsNumber, IsOptional, IsString, MaxLength } from 'class-validator'; import { PostType, PostVisibility } from '@prisma/client'; import { Transform } from 'class-transformer'; import { IsParentIdAllowed } from '../decorators/is-parent-id-allowed.decorator'; @@ -62,5 +62,23 @@ export class CreatePostDto { }) media?: Express.Multer.File[]; + @ApiPropertyOptional({ + type: [Number], + description: 'Optional array of user IDs to mention', + example: [1, 2, 3], + }) + @IsOptional() + @IsArray() + @IsNumber({}, { each: true }) + @Transform(({ value }) => { + if (!value) return undefined; + const parsed = JSON.parse(value); + if (Array.isArray(parsed)) { + return parsed.map((v) => Number(v)); + } + return value; + }) + mentionsIds?: number[]; + userId: number; } diff --git a/src/post/services/post.service.ts b/src/post/services/post.service.ts index 84f03d6..b6c2df6 100644 --- a/src/post/services/post.service.ts +++ b/src/post/services/post.service.ts @@ -241,7 +241,7 @@ export class PostService { @InjectQueue(RedisQueues.postQueue.name) private readonly postQueue: Queue, private readonly eventEmitter: EventEmitter2, - ) {} + ) { } private extractHashtags(content: string): string[] { if (!content) return []; @@ -389,6 +389,13 @@ export class PostService { })), }); + await tx.mention.createMany({ + data: postData.mentionsIds?.map((id) => ({ + post_id: post.id, + user_id: id, + })) ?? [], + }); + // Handle notifications after transaction if (postData.parentId) { // Fetch parent post to get author @@ -424,11 +431,30 @@ export class PostService { }); } + private async checkUsersExistence(usersIds: number[]) { + if (usersIds.length === 0) { + return; + } + const uniqueIds = Array.from(new Set(usersIds)); + + const existingUsers = await this.prismaService.user.findMany({ + where: { id: { in: uniqueIds } }, + select: { id: true }, + }); + + + if (existingUsers.length !== uniqueIds.length) { + throw new UnprocessableEntityException("Some user IDs are invalid") + } + } + async createPost(createPostDto: CreatePostDto) { let urls: string[] = []; try { const { content, media, userId } = createPostDto; urls = await this.storageService.uploadFiles(media); + console.log(createPostDto.mentionsIds) + await this.checkUsersExistence(createPostDto.mentionsIds ?? []) const hashtags = this.extractHashtags(content); @@ -484,16 +510,16 @@ export class PostService { const where = hasFilters ? { - ...(userId && { user_id: userId }), - ...(hashtag && { hashtags: { some: { tag: hashtag } } }), - ...(type && { type }), - is_deleted: false, - } + ...(userId && { user_id: userId }), + ...(hashtag && { hashtags: { some: { tag: hashtag } } }), + ...(type && { type }), + is_deleted: false, + } : { - // TODO: improve this fallback - visibility: PostVisibility.EVERY_ONE, // fallback: only public posts - is_deleted: false, - }; + // TODO: improve this fallback + visibility: PostVisibility.EVERY_ONE, // fallback: only public posts + is_deleted: false, + }; const posts = await this.prismaService.post.findMany({ where, @@ -684,12 +710,12 @@ export class PostService { isSimpleRepost && post.repostedBy ? post.repostedBy : { - userId: post.user_id, - username: post.username, - verified: post.isVerified, - name: post.authorName || post.username, - avatar: post.authorProfileImage, - }; + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + }; // Build originalPostData let originalPostData: any = null; @@ -1667,12 +1693,12 @@ export class PostService { isSimpleRepost && post.repostedBy ? post.repostedBy : { - userId: post.user_id, - username: post.username, - verified: post.isVerified, - name: post.authorName || post.username, - avatar: post.authorProfileImage, - }; + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + }; return { // User Information (reposter for simple reposts, author otherwise) @@ -1706,42 +1732,42 @@ export class PostService { originalPostData: isSimpleRepost || isQuote ? { - userId: post.user_id, - username: post.username, - verified: post.isVerified, - name: post.authorName || post.username, - avatar: post.authorProfileImage, - postId: post.id, - date: post.created_at, - likesCount: post.likeCount, - retweetsCount: post.repostCount, - commentsCount: post.replyCount, - isLikedByMe: post.isLikedByMe, - isFollowedByMe: post.isFollowedByMe, - isRepostedByMe: post.isRepostedByMe || false, - text: post.content || '', - media: Array.isArray(post.mediaUrls) ? post.mediaUrls : [], - ...(isQuote && post.originalPost - ? { - // Override with quoted post data for quotes - userId: post.originalPost.author.userId, - username: post.originalPost.author.username, - verified: post.originalPost.author.isVerified, - name: post.originalPost.author.name, - avatar: post.originalPost.author.avatar, - postId: post.originalPost.postId, - date: post.originalPost.createdAt, - likesCount: post.originalPost.likeCount, - retweetsCount: post.originalPost.repostCount, - commentsCount: post.originalPost.replyCount, - isLikedByMe: post.originalPost.isLikedByMe, - isFollowedByMe: post.originalPost.isFollowedByMe, - isRepostedByMe: post.originalPost.isRepostedByMe, - text: post.originalPost.content || '', - media: post.originalPost.media || [], - } - : {}), - } + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + postId: post.id, + date: post.created_at, + likesCount: post.likeCount, + retweetsCount: post.repostCount, + commentsCount: post.replyCount, + isLikedByMe: post.isLikedByMe, + isFollowedByMe: post.isFollowedByMe, + isRepostedByMe: post.isRepostedByMe || false, + text: post.content || '', + media: Array.isArray(post.mediaUrls) ? post.mediaUrls : [], + ...(isQuote && post.originalPost + ? { + // Override with quoted post data for quotes + userId: post.originalPost.author.userId, + username: post.originalPost.author.username, + verified: post.originalPost.author.isVerified, + name: post.originalPost.author.name, + avatar: post.originalPost.author.avatar, + postId: post.originalPost.postId, + date: post.originalPost.createdAt, + likesCount: post.originalPost.likeCount, + retweetsCount: post.originalPost.repostCount, + commentsCount: post.originalPost.replyCount, + isLikedByMe: post.originalPost.isLikedByMe, + isFollowedByMe: post.originalPost.isFollowedByMe, + isRepostedByMe: post.originalPost.isRepostedByMe, + text: post.originalPost.content || '', + media: post.originalPost.media || [], + } + : {}), + } : undefined, // Scores data From 05dd04cc94be40b6a247516a6bdfc2bdcb59889d Mon Sep 17 00:00:00 2001 From: YousefAref72 Date: Wed, 3 Dec 2025 16:30:46 +0200 Subject: [PATCH 2/2] generalize post response in profile --- src/post/interfaces/post.interface.ts | 1 + src/post/services/like.service.ts | 30 +++++++++++++++++++++------ src/post/services/mention.service.ts | 24 +++++++++++++++------ src/post/services/post.service.ts | 12 ++++++++++- 4 files changed, 54 insertions(+), 13 deletions(-) diff --git a/src/post/interfaces/post.interface.ts b/src/post/interfaces/post.interface.ts index 0b68460..10567fc 100644 --- a/src/post/interfaces/post.interface.ts +++ b/src/post/interfaces/post.interface.ts @@ -38,6 +38,7 @@ export interface RawPost { media: Media[]; likes: { user_id: number; }[]; repostedBy: { user_id: number; }[]; + mentions: { user_id: number; }[]; } export interface TransformedPost { diff --git a/src/post/services/like.service.ts b/src/post/services/like.service.ts index a7250aa..3a4d918 100644 --- a/src/post/services/like.service.ts +++ b/src/post/services/like.service.ts @@ -3,14 +3,17 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { Services } from 'src/utils/constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { NotificationType } from 'src/notifications/enums/notification.enum'; +import { PostService } from './post.service'; @Injectable() export class LikeService { constructor( @Inject(Services.PRISMA) private readonly prismaService: PrismaService, + @Inject(Services.POST) + private readonly postService: PostService, private readonly eventEmitter: EventEmitter2, - ) {} + ) { } async togglePostLike(postId: number, userId: number) { const existingLike = await this.prismaService.like.findUnique({ @@ -85,15 +88,30 @@ export class LikeService { async getLikedPostsByUser(userId: number, page: number, limit: number) { const likes = await this.prismaService.like.findMany({ where: { user_id: userId }, - include: { post: true }, + select: { post_id: true, created_at: true }, orderBy: { created_at: 'desc' }, skip: (page - 1) * limit, take: limit, }); + + const likedPostsIds = likes.map(like => like.post_id); - return likes.map((like) => ({ - ...like.post, - liked_at: like.created_at, - })); + const likedPosts = await this.postService.findPosts({ + where: { + is_deleted: false, + id: { in: likedPostsIds } + }, + userId, + limit, + page + }) + const orderMap = new Map( + likes.map((m, index) => [m.post_id, index]) + ); + + likedPosts.sort( + (a, b) => orderMap.get(a.postId)! - orderMap.get(b.postId)! + ); + return likedPosts; } } diff --git a/src/post/services/mention.service.ts b/src/post/services/mention.service.ts index 2be0482..e580189 100644 --- a/src/post/services/mention.service.ts +++ b/src/post/services/mention.service.ts @@ -3,14 +3,17 @@ import { PrismaService } from 'src/prisma/prisma.service'; import { Services } from 'src/utils/constants'; import { EventEmitter2 } from '@nestjs/event-emitter'; import { NotificationType } from 'src/notifications/enums/notification.enum'; +import { PostService } from './post.service'; @Injectable() export class MentionService { constructor( @Inject(Services.PRISMA) private readonly prismaService: PrismaService, + @Inject(Services.POST) + private readonly postService: PostService, private readonly eventEmitter: EventEmitter2, - ) {} + ) { } private async checkUserExists(userId: number) { const user = await this.prismaService.user.findUnique({ @@ -74,17 +77,26 @@ export class MentionService { async getMentionedPosts(userId: number, page: number, limit: number) { const mentions = await this.prismaService.mention.findMany({ where: { user_id: userId }, - include: { post: true }, + select: { post_id: true, created_at: true }, distinct: ['post_id'], orderBy: { created_at: 'desc' }, skip: (page - 1) * limit, take: limit, }); - return mentions.map((mention) => ({ - ...mention.post, - mentionedAt: mention.created_at, - })); + const postsIds = mentions.map(mention => mention.post_id); + + const mentionPosts = await this.postService.findPosts({ + where: { + is_deleted: false, + id: { in: postsIds } + }, + userId, + limit, + page + }) + + return mentionPosts } async getMentionsForPost(postId: number, page: number = 1, limit: number = 10) { diff --git a/src/post/services/post.service.ts b/src/post/services/post.service.ts index b6c2df6..11d892d 100644 --- a/src/post/services/post.service.ts +++ b/src/post/services/post.service.ts @@ -282,7 +282,7 @@ export class PostService { return stats; } - private async findPosts(options: { + async findPosts(options: { where: PrismalSql.PostWhereInput; userId: number; page?: number; @@ -330,6 +330,7 @@ export class PostService { where: { user_id: userId }, select: { user_id: true }, }, + mentions: { where: { user_id: userId }, select: { user_id: true } }, }, skip: (page - 1) * limit, take: limit, @@ -1036,12 +1037,21 @@ export class PostService { url: m.media_url, type: m.type, })), + mentions: post.mentions, isRepost: false, isQuote: PostType.QUOTE === post.type, createdAt: post.created_at, })); } + // async getUserMedia(userId: number, page:number, limit: number){ + // return await this.prismaService.media.findMany({ + // where:{ + // user + // } + // }) + // } + async getUserReplies(userId: number, page: number, limit: number) { return await this.findPosts({ where: {