From 1b24ca42943b296631d34eb156c6f08b79b62134 Mon Sep 17 00:00:00 2001 From: boutmoun123 Date: Mon, 6 Jul 2026 15:17:53 +0300 Subject: [PATCH] Add reels feed support --- src/modules/feed/dto/feed-query.dto.ts | 7 +- src/modules/feed/feed.controller.ts | 7 + src/modules/feed/feed.service.spec.ts | 231 +++++++++++++++++++++++++ src/modules/feed/feed.service.ts | 77 +++++++-- 4 files changed, 304 insertions(+), 18 deletions(-) create mode 100644 src/modules/feed/feed.service.spec.ts diff --git a/src/modules/feed/dto/feed-query.dto.ts b/src/modules/feed/dto/feed-query.dto.ts index 7ebbf49..a75e82d 100644 --- a/src/modules/feed/dto/feed-query.dto.ts +++ b/src/modules/feed/dto/feed-query.dto.ts @@ -4,10 +4,13 @@ import { Transform, Type } from 'class-transformer'; import { PostType } from '../../../common/enums/post-type.enum'; import { toBoolean } from '../../../common/utils/query-transform.util'; +export const FEED_POST_TYPE_FILTERS = [...Object.values(PostType), 'reel'] as const; +export type FeedPostTypeFilter = (typeof FEED_POST_TYPE_FILTERS)[number]; + export class FeedQueryDto extends PaginationQueryDto { @IsOptional() - @IsEnum(PostType) - preferredPostType?: PostType; + @IsEnum(FEED_POST_TYPE_FILTERS) + preferredPostType?: FeedPostTypeFilter; @IsOptional() @Transform(toBoolean) diff --git a/src/modules/feed/feed.controller.ts b/src/modules/feed/feed.controller.ts index 705eabb..452abb2 100644 --- a/src/modules/feed/feed.controller.ts +++ b/src/modules/feed/feed.controller.ts @@ -18,6 +18,13 @@ export class FeedController { return this.feedService.getMyFeed(user.sub, query); } + @ApiBearerAuth() + @UseGuards(JwtAuthGuard) + @Get('reels') + async reels(@CurrentUser() user: JwtPayload, @Query() query: FeedQueryDto) { + return this.feedService.getReels(user.sub, query); + } + @ApiBearerAuth() @UseGuards(JwtAuthGuard) @Get('trending') diff --git a/src/modules/feed/feed.service.spec.ts b/src/modules/feed/feed.service.spec.ts new file mode 100644 index 0000000..c6520a3 --- /dev/null +++ b/src/modules/feed/feed.service.spec.ts @@ -0,0 +1,231 @@ +import { Types } from 'mongoose'; +import { PostType } from '../../common/enums/post-type.enum'; +import { PostVisibility } from '../../common/enums/post-visibility.enum'; +import { decodeOffsetCursor } from '../../common/utils/cursor.util'; +import { buildPostMediaResponse } from '../../common/utils/post-media-response.util'; +import { FeedService } from './feed.service'; + +const makePost = (input: Partial> = {}) => { + const id = input.id ?? new Types.ObjectId().toString(); + const authorId = input.authorId ?? new Types.ObjectId().toString(); + const postType = input.postType ?? PostType.TEXT; + const raw = { + _id: new Types.ObjectId(id), + id, + authorId: { + _id: new Types.ObjectId(authorId), + id: authorId, + username: `author_${authorId.slice(-4)}`, + isVerified: false, + }, + content: input.content ?? `${postType} content`, + postType, + visibility: input.visibility ?? PostVisibility.PUBLIC, + videoUrl: input.videoUrl ?? '', + hlsUrl: input.hlsUrl ?? '', + thumbnailUrl: input.thumbnailUrl ?? '', + likesCount: input.likesCount ?? 0, + commentsCount: input.commentsCount ?? 0, + savesCount: input.savesCount ?? 0, + shareCount: input.shareCount ?? 0, + viewCount: input.viewCount ?? 0, + playCount: input.playCount ?? 0, + commentsDisabled: input.commentsDisabled ?? false, + createdAt: input.createdAt ?? new Date(), + }; + + return { + ...raw, + toObject: () => ({ + ...raw, + media: buildPostMediaResponse(raw), + }), + }; +}; + +const createService = () => { + const feedRepository = { + findFollowingIds: jest.fn().mockResolvedValue([]), + findCandidatePosts: jest.fn().mockResolvedValue([]), + findTrendingPublicPosts: jest.fn().mockResolvedValue([]), + count: jest.fn().mockResolvedValue(0), + }; + const usersRepository = { + findById: jest.fn().mockResolvedValue({ + id: new Types.ObjectId().toString(), + musicGenres: [], + favoriteInstruments: [], + favoriteMaqamat: [], + musicRoles: [], + }), + }; + const cacheService = { + get: jest.fn(), + set: jest.fn(), + }; + const configService = { + get: jest.fn((key: string) => { + if (key === 'feedCache.enabled') { + return false; + } + return undefined; + }), + }; + const likesRepository = { + findLikedPostIds: jest.fn().mockResolvedValue([]), + }; + const savesRepository = { + findSavedPostIds: jest.fn().mockResolvedValue([]), + }; + const connection = { + collection: jest.fn(() => ({ + find: jest.fn(() => ({ + project: jest.fn().mockReturnThis(), + toArray: jest.fn().mockResolvedValue([]), + })), + })), + }; + + const service = new FeedService( + feedRepository as any, + usersRepository as any, + cacheService as any, + { getGlobalVersion: jest.fn(), bumpGlobalVersion: jest.fn() } as any, + configService as any, + likesRepository as any, + savesRepository as any, + { getSuggestions: jest.fn() } as any, + { + getPublicListings: jest.fn(), + getPublicInstruments: jest.fn(), + getPublicRepairShops: jest.fn(), + } as any, + { getInvisibleUserIds: jest.fn().mockResolvedValue([]) } as any, + connection as any, + ); + + return { service, feedRepository, likesRepository, savesRepository }; +}; + +describe('FeedService reels feed', () => { + it('keeps /feed/me without preferredPostType unfiltered', async () => { + const { service, feedRepository } = createService(); + const posts = [ + makePost({ postType: PostType.IMAGE }), + makePost({ postType: PostType.VIDEO, videoUrl: 'https://cdn.example.com/video.mp4' }), + ]; + feedRepository.findCandidatePosts.mockResolvedValue(posts); + + const result = (await service.getMyFeed(new Types.ObjectId().toString(), { + includeSuggestions: false, + limit: 10, + })) as any; + + expect(result.items).toHaveLength(2); + expect(result.items.map((item: any) => item.postType).sort()).toEqual(['image', 'video']); + expect(result.pagination.mode).toBe('cursor'); + }); + + it('maps preferredPostType=reel to video posts and normalizes response postType', async () => { + const { service, feedRepository, likesRepository, savesRepository } = createService(); + const reel = makePost({ + postType: PostType.VIDEO, + videoUrl: 'https://cdn.example.com/reel.mp4', + hlsUrl: 'https://cdn.example.com/reel.m3u8', + thumbnailUrl: 'https://cdn.example.com/reel.jpg', + likesCount: 2, + commentsCount: 3, + savesCount: 4, + shareCount: 5, + viewCount: 6, + playCount: 7, + commentsDisabled: true, + }); + const image = makePost({ postType: PostType.IMAGE }); + feedRepository.findCandidatePosts.mockResolvedValue([reel, image]); + likesRepository.findLikedPostIds.mockResolvedValue([reel.id]); + savesRepository.findSavedPostIds.mockResolvedValue([reel.id]); + + const result = (await service.getMyFeed(new Types.ObjectId().toString(), { + preferredPostType: 'reel', + includeSuggestions: false, + limit: 10, + })) as any; + + expect(feedRepository.findCandidatePosts).toHaveBeenCalledWith( + expect.objectContaining({ + $and: expect.arrayContaining([expect.objectContaining({ postType: PostType.VIDEO })]), + }), + expect.any(Number), + ); + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual( + expect.objectContaining({ + id: reel.id, + postType: 'reel', + feedItemType: 'post', + likedByMe: true, + savedByMe: true, + isSharedByMe: false, + commentsDisabled: true, + }), + ); + expect((result.items[0] as any).media).toEqual( + expect.objectContaining({ + preferredPlaybackUrl: 'https://cdn.example.com/reel.m3u8', + hlsUrl: 'https://cdn.example.com/reel.m3u8', + videoUrl: 'https://cdn.example.com/reel.mp4', + thumbnailUrl: 'https://cdn.example.com/reel.jpg', + }), + ); + expect((result.items[0] as any).engagement).toEqual( + expect.objectContaining({ + likesCount: 2, + commentsCount: 3, + savesCount: 4, + shareCount: 5, + viewCount: 6, + playCount: 7, + }), + ); + }); + + it('uses cursor pagination for reels', async () => { + const { service, feedRepository } = createService(); + feedRepository.findCandidatePosts.mockResolvedValue([ + makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:02:00.000Z') }), + makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:01:00.000Z') }), + makePost({ postType: PostType.VIDEO, createdAt: new Date('2026-07-06T10:00:00.000Z') }), + ]); + + const firstPage = (await service.getMyFeed(new Types.ObjectId().toString(), { + preferredPostType: 'reel', + includeSuggestions: false, + limit: 2, + })) as any; + const secondPage = (await service.getMyFeed(new Types.ObjectId().toString(), { + preferredPostType: 'reel', + includeSuggestions: false, + limit: 2, + cursor: firstPage.nextCursor ?? undefined, + })) as any; + + expect(firstPage.items).toHaveLength(2); + expect(firstPage.nextCursor).toBeTruthy(); + expect(decodeOffsetCursor(firstPage.nextCursor ?? undefined)).toBe(2); + expect(secondPage.items).toHaveLength(1); + expect(secondPage.nextCursor).toBeNull(); + }); + + it('exposes /feed/reels through the same reel contract', async () => { + const { service, feedRepository } = createService(); + feedRepository.findCandidatePosts.mockResolvedValue([ + makePost({ postType: PostType.VIDEO, videoUrl: 'https://cdn.example.com/reel.mp4' }), + ]); + + const result = (await service.getReels(new Types.ObjectId().toString(), { limit: 10 })) as any; + + expect(result.items).toHaveLength(1); + expect(result.items[0]).toEqual(expect.objectContaining({ postType: 'reel' })); + }); +}); diff --git a/src/modules/feed/feed.service.ts b/src/modules/feed/feed.service.ts index 618fffc..3782970 100644 --- a/src/modules/feed/feed.service.ts +++ b/src/modules/feed/feed.service.ts @@ -17,7 +17,7 @@ import { PostShareTarget } from '../posts/dto/share-post.dto'; import { SavesRepository } from '../saves/saves.repository'; import { UserDocument } from '../users/schemas/user.schema'; import { UsersRepository } from '../users/users.repository'; -import { FeedQueryDto } from './dto/feed-query.dto'; +import { FeedPostTypeFilter, FeedQueryDto } from './dto/feed-query.dto'; import { FeedRepository } from './feed.repository'; type FeedPostItem = Record & { @@ -119,6 +119,7 @@ export class FeedService { const page = query.page ?? 1; const radiusKm = query.radiusKm ?? 30; const skip = cursorOffset ?? (page - 1) * limit; + const requestedPostType = this.resolveRequestedPostType(query.preferredPostType); const [followingIds, invisibleUserIds] = await Promise.all([ this.feedRepository.findFollowingIds(currentUserId), @@ -126,6 +127,9 @@ export class FeedService { ]); const relationLookupMs = this.markTiming(timing); let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds); + if (requestedPostType) { + filter = { $and: [filter, { postType: requestedPostType }] }; + } let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); const firstCandidateLookupMs = this.markTiming(timing); @@ -137,17 +141,18 @@ export class FeedService { followingOnly ) { filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, invisibleUserIds); + if (requestedPostType) { + filter = { $and: [filter, { postType: requestedPostType }] }; + } candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); } const fallbackCandidateLookupMs = this.markTiming(timing); - const scored = candidates - .filter((post) => { - if (!query.preferredPostType) { - return true; - } - return post.postType === query.preferredPostType; - }) + const filteredCandidates = requestedPostType + ? candidates.filter((post) => post.postType === requestedPostType) + : candidates; + + const scored = filteredCandidates .map((post) => ({ post, score: this.scorePost({ @@ -173,10 +178,14 @@ export class FeedService { feedScore: Number(entry.score.toFixed(3)), })); const decoratedPosts = await this.decoratePostsForViewer(currentUserId, pagedPosts, followingIds); + const normalizedPosts = this.normalizePreferredPostTypeForResponse( + decoratedPosts, + query.preferredPostType, + ); const decorationMs = this.markTiming(timing); const items = includeSuggestions - ? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4) - : decoratedPosts; + ? await this.mixHomeFeedItems(currentUserId, normalizedPosts, query.suggestionInterval ?? 4) + : normalizedPosts; const cardsMs = this.markTiming(timing); const nextOffset = skip + pagedPosts.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; @@ -209,9 +218,10 @@ export class FeedService { followingCount: followingIds.length, invisibleUserCount: invisibleUserIds.length, candidateCount: candidates.length, + filteredCandidateCount: filteredCandidates.length, scoredCount: scored.length, itemCount: items.length, - postItemCount: decoratedPosts.length, + postItemCount: normalizedPosts.length, nextCursor, relationLookupMs, firstCandidateLookupMs, @@ -233,6 +243,14 @@ export class FeedService { }); } + async getReels(currentUserId: string, query: FeedQueryDto) { + return this.getMyFeed(currentUserId, { + ...query, + preferredPostType: 'reel', + includeSuggestions: false, + }); + } + async getTrending(currentUserId: string, query: FeedQueryDto) { const timing = this.startTiming('feed.trending'); const cacheEnabled = @@ -272,8 +290,9 @@ export class FeedService { if (invisibleUserIds.length) { trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) }; } - if (query.preferredPostType) { - trendingFilter.postType = query.preferredPostType; + const requestedPostType = this.resolveRequestedPostType(query.preferredPostType); + if (requestedPostType) { + trendingFilter.postType = requestedPostType; } const [rows, total] = await Promise.all([ @@ -286,11 +305,15 @@ export class FeedService { rows.map((item) => item.toObject() as unknown as Record), followingIds, ); + const normalizedPosts = this.normalizePreferredPostTypeForResponse( + decoratedPosts, + query.preferredPostType, + ); const decorationMs = this.markTiming(timing); const nextOffset = skip + rows.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; - const result = buildPaginatedResponse(decoratedPosts, { + const result = buildPaginatedResponse(normalizedPosts, { page, limit, total, @@ -316,7 +339,7 @@ export class FeedService { preferredPostType: query.preferredPostType ?? '', followingCount: followingIds.length, invisibleUserCount: invisibleUserIds.length, - itemCount: decoratedPosts.length, + itemCount: normalizedPosts.length, total, nextCursor, relationLookupMs, @@ -537,7 +560,7 @@ export class FeedService { currentUserId: string; followingIds: string[]; post: Record; - preferredPostType?: PostType; + preferredPostType?: FeedPostTypeFilter; radiusKm: number; }): number { const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input; @@ -680,6 +703,28 @@ export class FeedService { return ''; } + private resolveRequestedPostType(preferredPostType?: FeedPostTypeFilter): PostType | null { + if (!preferredPostType) { + return null; + } + + return preferredPostType === 'reel' ? PostType.VIDEO : preferredPostType; + } + + private normalizePreferredPostTypeForResponse( + items: T[], + preferredPostType?: FeedPostTypeFilter, + ): T[] { + if (preferredPostType !== 'reel') { + return items; + } + + return items.map((item) => ({ + ...item, + postType: 'reel', + })); + } + private startTiming(scope: string): { scope: string; startedAt: number; lastAt: number } { const now = performance.now(); return { scope, startedAt: now, lastAt: now };