diff --git a/src/post/dto/search-posts.dto.ts b/src/post/dto/search-posts.dto.ts index a0f7c02..24ed9cf 100644 --- a/src/post/dto/search-posts.dto.ts +++ b/src/post/dto/search-posts.dto.ts @@ -9,11 +9,17 @@ import { Max, Min, MinLength, + IsDateString, } from 'class-validator'; import { Type } from 'class-transformer'; import { PaginationDto } from 'src/common/dto/pagination.dto'; import { PostType } from '@prisma/client'; +export enum SearchOrderBy { + RELEVANCE = 'relevance', + LATEST = 'latest', +} + export class SearchPostsDto extends PaginationDto { @ApiProperty({ description: 'Search query to match against post content (supports partial matching)', @@ -50,4 +56,23 @@ export class SearchPostsDto extends PaginationDto { @Min(0) @Max(1) similarityThreshold?: number = 0.1; + + @ApiPropertyOptional({ + description: 'Filter posts created before this date (ISO 8601 format)', + example: '2024-12-01T00:00:00Z', + }) + @IsOptional() + @IsDateString() + before_date?: string; + + @ApiPropertyOptional({ + description: 'Order search results by relevance or latest', + enum: SearchOrderBy, + example: SearchOrderBy.RELEVANCE, + }) + @IsOptional() + @IsEnum(SearchOrderBy, { + message: `order_by must be one of: ${Object.values(SearchOrderBy).join(', ')}`, + }) + order_by?: SearchOrderBy = SearchOrderBy.RELEVANCE; } diff --git a/src/post/dto/search-response.dto.ts b/src/post/dto/search-response.dto.ts new file mode 100644 index 0000000..d07db97 --- /dev/null +++ b/src/post/dto/search-response.dto.ts @@ -0,0 +1,108 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { FeedPostDto, MediaDto, OriginalPostDataDto } from './timeline-feed-reponse.dto'; + +export class SearchPostDto { + @ApiProperty({ example: 1, description: 'User ID of the author' }) + userId: number; + + @ApiProperty({ example: 'johndoe', description: 'Username of the author' }) + username: string; + + @ApiProperty({ example: true, description: 'Whether the author is verified' }) + verified: boolean; + + @ApiProperty({ example: 'John Doe', description: 'Display name of the author' }) + name: string; + + @ApiProperty({ + example: 'https://example.com/avatar.jpg', + description: 'Avatar URL', + nullable: true, + }) + avatar: string | null; + + @ApiProperty({ example: 456, description: 'Post ID' }) + postId: number; + + @ApiProperty({ example: '2023-11-21T12:00:00Z', description: 'Post creation date' }) + date: Date; + + @ApiProperty({ example: 200, description: 'Number of likes' }) + likesCount: number; + + @ApiProperty({ example: 75, description: 'Number of reposts' }) + retweetsCount: number; + + @ApiProperty({ example: 30, description: 'Number of replies' }) + commentsCount: number; + + @ApiProperty({ example: true, description: 'Whether current user liked this post' }) + isLikedByMe: boolean; + + @ApiProperty({ example: false, description: 'Whether current user follows the author' }) + isFollowedByMe: boolean; + + @ApiProperty({ example: false, description: 'Whether current user reposted this post' }) + isRepostedByMe: boolean; + + @ApiProperty({ example: 'This is a post content', description: 'Post content' }) + text: string; + + @ApiProperty({ type: [MediaDto], description: 'Media attachments' }) + media: MediaDto[]; + + @ApiProperty({ example: false, description: 'Whether this is a repost' }) + isRepost: boolean; + + @ApiProperty({ example: false, description: 'Whether this is a quote tweet' }) + isQuote: boolean; + + @ApiProperty({ + type: OriginalPostDataDto, + description: 'Original post information (for quotes and reposts)', + nullable: true, + required: false, + }) + originalPostData?: OriginalPostDataDto; +} + +export class SearchPostsDataDto { + @ApiProperty({ + type: [SearchPostDto], + description: 'Array of posts matching search criteria', + }) + posts: SearchPostDto[]; +} + +export class SearchPostsResponseDto { + @ApiProperty({ example: 'success', description: 'Response status' }) + status: string; + + @ApiProperty({ + example: 'Search results retrieved successfully', + description: 'Response message', + }) + message: string; + + @ApiProperty({ + type: SearchPostsDataDto, + description: 'Search results data', + }) + data: SearchPostsDataDto; + + @ApiProperty({ + description: 'Pagination metadata', + example: { + totalItems: 100, + page: 1, + limit: 10, + totalPages: 10, + }, + }) + metadata: { + totalItems: number; + page: number; + limit: number; + totalPages: number; + }; +} diff --git a/src/post/post.controller.ts b/src/post/post.controller.ts index 96da5e5..c2d2076 100644 --- a/src/post/post.controller.ts +++ b/src/post/post.controller.ts @@ -44,6 +44,7 @@ import { } from './dto/like-response.dto'; import { ToggleRepostResponseDto, GetRepostersResponseDto } from './dto/repost-response.dto'; import { SearchByHashtagResponseDto } from './dto/hashtag-search-response.dto'; +import { SearchPostsResponseDto } from './dto/search-response.dto'; import { ErrorResponseDto } from 'src/common/dto/error-response.dto'; import { JwtAuthGuard } from 'src/auth/guards/jwt-auth/jwt-auth.guard'; @@ -71,7 +72,7 @@ export class PostController { private readonly repostService: RepostService, @Inject(Services.MENTION) private readonly mentionService: MentionService, - ) { } + ) {} @Post() @UseGuards(JwtAuthGuard) @@ -212,6 +213,20 @@ export class PostController { description: 'Minimum similarity threshold (0.0 to 1.0). Lower values return more results.', example: 0.1, }) + @ApiQuery({ + name: 'before_date', + required: false, + type: String, + description: 'Filter posts created before this date (ISO 8601 format)', + example: '2024-12-01T00:00:00Z', + }) + @ApiQuery({ + name: 'order_by', + required: false, + enum: ['relevance', 'latest'], + description: 'Order search results by relevance (default) or latest (created_at desc)', + example: 'relevance', + }) @ApiQuery({ name: 'page', required: false, @@ -229,7 +244,7 @@ export class PostController { @ApiResponse({ status: HttpStatus.OK, description: 'Search results retrieved successfully', - type: GetPostsResponseDto, + type: SearchPostsResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, @@ -250,7 +265,7 @@ export class PostController { return { status: 'success', message: 'Search results retrieved successfully', - data: posts, + data: { posts }, metadata: { totalItems, page, @@ -306,7 +321,7 @@ export class PostController { @ApiResponse({ status: HttpStatus.OK, description: 'Posts with hashtag retrieved successfully', - type: SearchByHashtagResponseDto, + type: SearchPostsResponseDto, }) @ApiResponse({ status: HttpStatus.BAD_REQUEST, @@ -330,7 +345,7 @@ export class PostController { return { status: 'success', message: `Posts with hashtag #${hashtag} retrieved successfully`, - data: posts, + data: { posts }, metadata: { hashtag, totalItems, @@ -1036,11 +1051,7 @@ export class PostController { @Query('page') page: number = 1, @Query('limit') limit: number = 10, ) { - const posts = await this.postService.getUserPosts( - userId, - +page, - +limit, - ); + const posts = await this.postService.getUserPosts(userId, +page, +limit); return { status: 'success', @@ -1091,11 +1102,7 @@ export class PostController { @Query('page') page: number = 1, @Query('limit') limit: number = 10, ) { - const replies = await this.postService.getUserReplies( - userId, - +page, - +limit, - ); + const replies = await this.postService.getUserReplies(userId, +page, +limit); return { status: 'success', diff --git a/src/post/services/post.service.ts b/src/post/services/post.service.ts index 1fe86b2..84f03d6 100644 --- a/src/post/services/post.service.ts +++ b/src/post/services/post.service.ts @@ -1,4 +1,9 @@ -import { Inject, Injectable, NotFoundException, UnprocessableEntityException } from '@nestjs/common'; +import { + Inject, + Injectable, + NotFoundException, + UnprocessableEntityException, +} from '@nestjs/common'; import { PrismaService } from 'src/prisma/prisma.service'; import { RedisQueues, Services } from 'src/utils/constants'; import { CreatePostDto } from '../dto/create-post.dto'; @@ -17,8 +22,6 @@ import { NotificationType } from 'src/notifications/enums/notification.enum'; import { MLService } from './ml.service'; import { RawPost, TransformedPost } from '../interfaces/post.interface'; - - // This interface now reflects the complex object returned by our query export interface FeedPostResponse { @@ -238,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 []; @@ -279,7 +282,6 @@ export class PostService { return stats; } - private async findPosts(options: { where: PrismalSql.PostWhereInput; userId: number; @@ -334,19 +336,18 @@ export class PostService { orderBy: { created_at: 'desc', }, - }) - const counts = await Promise.all(posts.map(post => this.getPostCounts(post.id))); + }); + const counts = await Promise.all(posts.map((post) => this.getPostCounts(post.id))); const postsWithCounts = posts.map((post, index) => ({ ...post, quoteCount: counts[index].quotes, - replyCount: counts[index].replies + replyCount: counts[index].replies, })); return this.transformPost(postsWithCounts); } - private async createPostTransaction( postData: CreatePostDto, hashtags: string[], @@ -433,11 +434,7 @@ export class PostService { const mediaWithType = this.getMediaWithType(urls, media); - const post = await this.createPostTransaction( - createPostDto, - hashtags, - mediaWithType, - ); + const post = await this.createPostTransaction(createPostDto, hashtags, mediaWithType); if (post.content) { await this.addToSummarizationQueue({ postContent: post.content, postId: post.id }); @@ -459,10 +456,7 @@ export class PostService { } private async addToSummarizationQueue(job: SummarizeJob) { - await this.postQueue.add( - RedisQueues.postQueue.processes.summarizePostContent, - job - ); + await this.postQueue.add(RedisQueues.postQueue.processes.summarizePostContent, job); } async summarizePost(postId: number) { @@ -490,16 +484,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, @@ -510,7 +504,7 @@ export class PostService { return posts; } - async searchPosts(searchDto: SearchPostsDto, currentUserId?: number) { + async searchPosts(searchDto: SearchPostsDto, currentUserId: number) { const { searchQuery, userId, @@ -518,6 +512,8 @@ export class PostService { page = 1, limit = 10, similarityThreshold = 0.1, + before_date, + order_by = 'relevance', } = searchDto; const offset = (page - 1) * limit; @@ -536,6 +532,17 @@ export class PostService { ` : PrismalSql.empty; + // Build before_date filter + const beforeDateFilter = before_date + ? PrismalSql.sql`AND p.created_at < ${before_date}::timestamp` + : PrismalSql.empty; + + // Build ORDER BY clause + const orderByClause = + order_by === 'latest' + ? PrismalSql.sql`ORDER BY p.created_at DESC` + : PrismalSql.sql`ORDER BY relevance DESC, p.created_at DESC`; + const countResult = await this.prismaService.$queryRaw<[{ count: bigint }]>( PrismalSql.sql` SELECT COUNT(DISTINCT p.id) as count @@ -545,65 +552,216 @@ export class PostService { ${userId ? PrismalSql.sql`AND p.user_id = ${userId}` : PrismalSql.empty} ${type ? PrismalSql.sql`AND p.type = ${type}::"PostType"` : PrismalSql.empty} AND similarity(p.content, ${searchQuery}) > ${similarityThreshold} + ${beforeDateFilter} ${blockMuteFilter} `, ); const totalItems = Number(countResult[0]?.count || 0); - const posts = await this.prismaService.$queryRaw( + const posts = await this.prismaService.$queryRaw( PrismalSql.sql` SELECT - p.*, + p.id, + p.user_id, + p.content, + p.created_at, + p.type, + p.visibility, + p.parent_id, + p.is_deleted, similarity(p.content, ${searchQuery}) as relevance, - json_build_object( - 'id', u.id, - 'username', u.username, - 'name', pr.name, - 'profile_image_url', pr.profile_image_url - ) as "User", + false as "isRepost", + p.created_at as "effectiveDate", + NULL::jsonb as "repostedBy", + + -- User/Author info + u.username, + u.is_verifed as "isVerified", + COALESCE(pr.name, u.username) as "authorName", + pr.profile_image_url as "authorProfileImage", + + -- Engagement counts + COUNT(DISTINCT l.user_id)::int as "likeCount", + COUNT(DISTINCT CASE WHEN reply.id IS NOT NULL THEN reply.id END)::int as "replyCount", + COUNT(DISTINCT r.user_id)::int as "repostCount", + + -- Author stats (dummy values for consistency with feed structure) + 0 as "followersCount", + 0 as "followingCount", + 0 as "postsCount", + + -- Content features (dummy values for consistency) + false as "hasMedia", + 0 as "hashtagCount", + 0 as "mentionCount", + + -- User interaction flags + EXISTS(SELECT 1 FROM "Like" WHERE post_id = p.id AND user_id = ${currentUserId}) as "isLikedByMe", + EXISTS(SELECT 1 FROM follows WHERE "followerId" = ${currentUserId} AND "followingId" = p.user_id) as "isFollowedByMe", + EXISTS(SELECT 1 FROM "Repost" WHERE post_id = p.id AND user_id = ${currentUserId}) as "isRepostedByMe", + + -- Media URLs (as JSON array) COALESCE( - json_agg( - DISTINCT jsonb_build_object('media_url', m.media_url, 'type', m.type) - ) FILTER (WHERE m.id IS NOT NULL), - '[]' - ) as media, - json_build_object( - 'likes', COUNT(DISTINCT l.user_id), - 'repostedBy', COUNT(DISTINCT r.user_id), - 'Replies', COUNT(DISTINCT reply.id) - ) as "_count" + (SELECT json_agg(json_build_object('url', m.media_url, 'type', m.type)) + FROM "Media" m WHERE m.post_id = p.id), + '[]'::json + ) as "mediaUrls", + + -- Original post for quotes only + CASE + WHEN p.parent_id IS NOT NULL AND p.type = 'QUOTE' THEN + (SELECT json_build_object( + 'postId', op.id, + 'content', op.content, + 'createdAt', op.created_at, + 'likeCount', COALESCE((SELECT COUNT(*)::int FROM "Like" WHERE post_id = op.id), 0), + 'repostCount', COALESCE((SELECT COUNT(*)::int FROM "Repost" WHERE post_id = op.id), 0), + 'replyCount', COALESCE((SELECT COUNT(*)::int FROM posts WHERE parent_id = op.id AND is_deleted = false), 0), + 'isLikedByMe', EXISTS(SELECT 1 FROM "Like" WHERE post_id = op.id AND user_id = ${currentUserId}), + 'isFollowedByMe', EXISTS(SELECT 1 FROM follows WHERE "followerId" = ${currentUserId} AND "followingId" = op.user_id), + 'isRepostedByMe', EXISTS(SELECT 1 FROM "Repost" WHERE post_id = op.id AND user_id = ${currentUserId}), + 'author', json_build_object( + 'userId', ou.id, + 'username', ou.username, + 'isVerified', ou.is_verifed, + 'name', COALESCE(opr.name, ou.username), + 'avatar', opr.profile_image_url + ), + 'media', COALESCE( + (SELECT json_agg(json_build_object('url', om.media_url, 'type', om.type)) + FROM "Media" om WHERE om.post_id = op.id), + '[]'::json + ) + ) + FROM posts op + LEFT JOIN "User" ou ON ou.id = op.user_id + LEFT JOIN profiles opr ON opr.user_id = ou.id + WHERE op.id = p.parent_id AND op.is_deleted = false) + ELSE NULL + END as "originalPost", + + -- Dummy personalization score (not used but required for interface) + 0::double precision as "personalizationScore" + FROM posts p LEFT JOIN "User" u ON u.id = p.user_id LEFT JOIN profiles pr ON pr.user_id = u.id - LEFT JOIN "Media" m ON m.post_id = p.id LEFT JOIN "Like" l ON l.post_id = p.id LEFT JOIN "Repost" r ON r.post_id = p.id - LEFT JOIN posts reply ON reply.parent_id = p.id AND reply.type = 'REPLY' + LEFT JOIN posts reply ON reply.parent_id = p.id AND reply.type = 'REPLY' AND reply.is_deleted = false WHERE p.is_deleted = false ${userId ? PrismalSql.sql`AND p.user_id = ${userId}` : PrismalSql.empty} ${type ? PrismalSql.sql`AND p.type = ${type}::"PostType"` : PrismalSql.empty} AND similarity(p.content, ${searchQuery}) > ${similarityThreshold} + ${beforeDateFilter} ${blockMuteFilter} - GROUP BY p.id, u.id, u.username, pr.name, pr.profile_image_url - ORDER BY - relevance DESC, - p.created_at DESC + GROUP BY p.id, u.id, u.username, u.is_verifed, pr.name, pr.profile_image_url + ${orderByClause} LIMIT ${limit} OFFSET ${offset} `, ); + const formattedPosts = posts.map((post) => this.transformToFeedResponseWithoutScores(post)); + return { - posts, + posts: formattedPosts, totalItems, page, limit, }; } - async searchPostsByHashtag(searchDto: SearchByHashtagDto, currentUserId?: number) { + private transformToFeedResponseWithoutScores( + post: PostWithAllData, + ): Omit { + const isQuote = post.type === PostType.QUOTE && !!post.parent_id; + const isSimpleRepost = post.isRepost && !isQuote; + + const topLevelUser = + isSimpleRepost && post.repostedBy + ? post.repostedBy + : { + userId: post.user_id, + username: post.username, + verified: post.isVerified, + name: post.authorName || post.username, + avatar: post.authorProfileImage, + }; + + // Build originalPostData + let originalPostData: any = null; + + if (isSimpleRepost) { + // For simple reposts, originalPostData is the actual post being reposted + originalPostData = { + 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 : [], + }; + } else if (isQuote && post.originalPost) { + // For quote tweets, originalPostData is the post being quoted + originalPostData = { + 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 || [], + }; + } + + return { + // User Information (reposter for simple reposts, author otherwise) + userId: topLevelUser.userId, + username: topLevelUser.username, + verified: topLevelUser.verified, + name: topLevelUser.name, + avatar: topLevelUser.avatar, + + // Tweet Metadata (always present) + postId: post.id, + date: isSimpleRepost && post.effectiveDate ? post.effectiveDate : post.created_at, + likesCount: post.likeCount, + retweetsCount: post.repostCount, + commentsCount: post.replyCount, + + // User Interaction Flags + isLikedByMe: post.isLikedByMe, + isFollowedByMe: post.isFollowedByMe, + isRepostedByMe: post.isRepostedByMe || false, + text: isSimpleRepost ? '' : post.content || '', + media: isSimpleRepost ? [] : Array.isArray(post.mediaUrls) ? post.mediaUrls : [], + isRepost: isSimpleRepost, + isQuote: isQuote, + originalPostData, + }; + } + + async searchPostsByHashtag(searchDto: SearchByHashtagDto, currentUserId: number) { const { hashtag, userId, type, page = 1, limit = 10 } = searchDto; const offset = (page - 1) * limit; @@ -614,127 +772,155 @@ export class PostService { // Build block/mute filters const blockMuteFilter = currentUserId - ? { - AND: [ - { - NOT: { - User: { - Blockers: { - some: { - blockerId: currentUserId, - }, - }, - }, - }, - }, - { - NOT: { - User: { - Blocked: { - some: { - blockedId: currentUserId, - }, - }, - }, - }, - }, - { - NOT: { - User: { - Muters: { - some: { - muterId: currentUserId, - }, - }, - }, - }, - }, - ], - } - : {}; + ? PrismalSql.sql` + AND NOT EXISTS ( + SELECT 1 FROM blocks WHERE "blockerId" = ${currentUserId} AND "blockedId" = p.user_id + ) + AND NOT EXISTS ( + SELECT 1 FROM blocks WHERE "blockedId" = ${currentUserId} AND "blockerId" = p.user_id + ) + AND NOT EXISTS ( + SELECT 1 FROM mutes WHERE "muterId" = ${currentUserId} AND "mutedId" = p.user_id + ) + ` + : PrismalSql.empty; // Count total posts with this hashtag - const countResult = await this.prismaService.post.count({ - where: { - is_deleted: false, - hashtags: { - some: { - tag: normalizedHashtag, - }, - }, - ...(userId && { user_id: userId }), - ...(type && { type }), - ...blockMuteFilter, - }, - }); + const countResult = await this.prismaService.$queryRaw<[{ count: bigint }]>( + PrismalSql.sql` + SELECT COUNT(DISTINCT p.id) as count + FROM posts p + INNER JOIN "_PostHashtags" ph ON ph."B" = p.id + INNER JOIN "Hashtag" h ON h.id = ph."A" + WHERE + p.is_deleted = false + AND h.tag = ${normalizedHashtag} + ${userId ? PrismalSql.sql`AND p.user_id = ${userId}` : PrismalSql.empty} + ${type ? PrismalSql.sql`AND p.type = ${type}::"PostType"` : PrismalSql.empty} + ${blockMuteFilter} + `, + ); - // Get posts with the hashtag - const posts = await this.prismaService.post.findMany({ - where: { - is_deleted: false, - hashtags: { - some: { - tag: normalizedHashtag, - }, - }, - ...(userId && { user_id: userId }), - ...(type && { type }), - ...blockMuteFilter, - }, - include: { - User: { - select: { - id: true, - username: true, - Profile: { - select: { - name: true, - profile_image_url: true, - }, - }, - }, - }, - hashtags: { - select: { - id: true, - tag: true, - }, - }, - media: { - select: { - media_url: true, - type: true, - }, - }, - _count: { - select: { - likes: true, - repostedBy: true, - Replies: true, - }, - }, - }, - orderBy: { - created_at: 'desc', - }, - skip: offset, - take: limit, - }); + const totalItems = Number(countResult[0]?.count || 0); + + const posts = await this.prismaService.$queryRaw( + PrismalSql.sql` + SELECT + p.id, + p.user_id, + p.content, + p.created_at, + p.type, + p.visibility, + p.parent_id, + p.is_deleted, + false as "isRepost", + p.created_at as "effectiveDate", + NULL::jsonb as "repostedBy", + + -- User/Author info + u.username, + u.is_verifed as "isVerified", + COALESCE(pr.name, u.username) as "authorName", + pr.profile_image_url as "authorProfileImage", + + -- Engagement counts + COUNT(DISTINCT l.user_id)::int as "likeCount", + COUNT(DISTINCT CASE WHEN reply.id IS NOT NULL THEN reply.id END)::int as "replyCount", + COUNT(DISTINCT r.user_id)::int as "repostCount", + + -- Author stats (dummy values for consistency) + 0 as "followersCount", + 0 as "followingCount", + 0 as "postsCount", + + -- Content features (dummy values for consistency) + false as "hasMedia", + 0 as "hashtagCount", + 0 as "mentionCount", + + -- User interaction flags + EXISTS(SELECT 1 FROM "Like" WHERE post_id = p.id AND user_id = ${currentUserId}) as "isLikedByMe", + EXISTS(SELECT 1 FROM follows WHERE "followerId" = ${currentUserId} AND "followingId" = p.user_id) as "isFollowedByMe", + EXISTS(SELECT 1 FROM "Repost" WHERE post_id = p.id AND user_id = ${currentUserId}) as "isRepostedByMe", + + -- Media URLs (as JSON array) + COALESCE( + (SELECT json_agg(json_build_object('url', m.media_url, 'type', m.type)) + FROM "Media" m WHERE m.post_id = p.id), + '[]'::json + ) as "mediaUrls", + + -- Original post for quotes only + CASE + WHEN p.parent_id IS NOT NULL AND p.type = 'QUOTE' THEN + (SELECT json_build_object( + 'postId', op.id, + 'content', op.content, + 'createdAt', op.created_at, + 'likeCount', COALESCE((SELECT COUNT(*)::int FROM "Like" WHERE post_id = op.id), 0), + 'repostCount', COALESCE((SELECT COUNT(*)::int FROM "Repost" WHERE post_id = op.id), 0), + 'replyCount', COALESCE((SELECT COUNT(*)::int FROM posts WHERE parent_id = op.id AND is_deleted = false), 0), + 'isLikedByMe', EXISTS(SELECT 1 FROM "Like" WHERE post_id = op.id AND user_id = ${currentUserId}), + 'isFollowedByMe', EXISTS(SELECT 1 FROM follows WHERE "followerId" = ${currentUserId} AND "followingId" = op.user_id), + 'isRepostedByMe', EXISTS(SELECT 1 FROM "Repost" WHERE post_id = op.id AND user_id = ${currentUserId}), + 'author', json_build_object( + 'userId', ou.id, + 'username', ou.username, + 'isVerified', ou.is_verifed, + 'name', COALESCE(opr.name, ou.username), + 'avatar', opr.profile_image_url + ), + 'media', COALESCE( + (SELECT json_agg(json_build_object('url', om.media_url, 'type', om.type)) + FROM "Media" om WHERE om.post_id = op.id), + '[]'::json + ) + ) + FROM posts op + LEFT JOIN "User" ou ON ou.id = op.user_id + LEFT JOIN profiles opr ON opr.user_id = ou.id + WHERE op.id = p.parent_id AND op.is_deleted = false) + ELSE NULL + END as "originalPost", + + -- Dummy personalization score (not used but required for interface) + 0::double precision as "personalizationScore" + + FROM posts p + INNER JOIN "_PostHashtags" ph ON ph."B" = p.id + INNER JOIN "Hashtag" h ON h.id = ph."A" + LEFT JOIN "User" u ON u.id = p.user_id + LEFT JOIN profiles pr ON pr.user_id = u.id + LEFT JOIN "Like" l ON l.post_id = p.id + LEFT JOIN "Repost" r ON r.post_id = p.id + LEFT JOIN posts reply ON reply.parent_id = p.id AND reply.type = 'REPLY' AND reply.is_deleted = false + WHERE + p.is_deleted = false + AND h.tag = ${normalizedHashtag} + ${userId ? PrismalSql.sql`AND p.user_id = ${userId}` : PrismalSql.empty} + ${type ? PrismalSql.sql`AND p.type = ${type}::"PostType"` : PrismalSql.empty} + ${blockMuteFilter} + GROUP BY p.id, u.id, u.username, u.is_verifed, pr.name, pr.profile_image_url + ORDER BY p.created_at DESC + LIMIT ${limit} + OFFSET ${offset} + `, + ); + + // Transform to feed response format (without scores) + const formattedPosts = posts.map((post) => this.transformToFeedResponseWithoutScores(post)); return { - posts, - totalItems: countResult, + posts: formattedPosts, + totalItems, page, limit, hashtag: normalizedHashtag, }; } - private async getReposts( - userId: number, - page: number, - limit: number, - ) { + private async getReposts(userId: number, page: number, limit: number) { return this.prismaService.repost.findMany({ where: { user_id: userId, @@ -896,12 +1082,11 @@ export class PostService { userId, page: 1, limit: 1, - }); if (!post) { throw new NotFoundException('Post not found'); } - return post + return post; } async getForYouFeed( @@ -1482,12 +1667,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) @@ -1521,42 +1706,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 diff --git a/src/profile/dto/profile-response.dto.ts b/src/profile/dto/profile-response.dto.ts index be0fc12..cd43fa0 100644 --- a/src/profile/dto/profile-response.dto.ts +++ b/src/profile/dto/profile-response.dto.ts @@ -112,6 +112,18 @@ export class ProfileResponseDto { }) is_muted_by_me?: boolean; + @ApiPropertyOptional({ + description: 'Whether the current user follows this profile', + example: false, + }) + is_followed_by_me?: boolean; + + @ApiPropertyOptional({ + description: 'Whether the user is verified', + example: true, + }) + verified?: boolean; + @ApiProperty({ description: 'Profile creation timestamp', example: '2025-01-01T00:00:00.000Z', diff --git a/src/profile/dto/profile-with-follow-status-response.dto.ts b/src/profile/dto/profile-with-follow-status-response.dto.ts index 9ec88c2..d37463a 100644 --- a/src/profile/dto/profile-with-follow-status-response.dto.ts +++ b/src/profile/dto/profile-with-follow-status-response.dto.ts @@ -2,9 +2,4 @@ import { ApiProperty } from '@nestjs/swagger'; import { ProfileResponseDto } from './profile-response.dto'; export class ProfileWithFollowStatusDto extends ProfileResponseDto { - @ApiProperty({ - description: 'Whether the current user is following this profile', - example: true, - }) - is_followed_by_me: boolean; } diff --git a/src/profile/profile.service.ts b/src/profile/profile.service.ts index 597ab88..75a03ad 100644 --- a/src/profile/profile.service.ts +++ b/src/profile/profile.service.ts @@ -19,6 +19,7 @@ export class ProfileService { email: true, role: true, created_at: true, + is_verified: true, _count: { select: { Followers: true, @@ -58,6 +59,7 @@ export class ProfileService { is_been_blocked: isBeenBlocked, is_blocked_by_me: isBlockedByMe, is_muted_by_me: isMutedByMe, + verified: User.is_verified || false, }; } @@ -366,7 +368,51 @@ export class ProfileService { const totalPages = Math.ceil(total / limit); - const profilesWithCounts = profiles.map((profile) => this.formatProfileResponse(profile)); + // Get follow and mute status for each profile if user is authenticated + let followStatusMap = new Map(); + let muteStatusMap = new Map(); + + if (currentUserId && profiles.length > 0) { + const profileUserIds = profiles.map((p) => p.user_id); + + // Batch check follow status + const followRelations = await this.prismaService.follow.findMany({ + where: { + followerId: currentUserId, + followingId: { + in: profileUserIds, + }, + }, + select: { + followingId: true, + }, + }); + followRelations.forEach((rel) => followStatusMap.set(rel.followingId, true)); + + // Batch check mute status + const muteRelations = await this.prismaService.mute.findMany({ + where: { + muterId: currentUserId, + mutedId: { + in: profileUserIds, + }, + }, + select: { + mutedId: true, + }, + }); + muteRelations.forEach((rel) => muteStatusMap.set(rel.mutedId, true)); + } + + const profilesWithCounts = profiles.map((profile) => { + const formatted = this.formatProfileResponse(profile); + return { + ...formatted, + is_followed_by_me: followStatusMap.get(profile.user_id) || false, + is_muted_by_me: muteStatusMap.get(profile.user_id) || false, + verified: profile.User.is_verified || false, + }; + }); return { profiles: profilesWithCounts,