Add reels feed support
هذا الالتزام موجود في:
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
|
||||
231
src/modules/feed/feed.service.spec.ts
Normal file
231
src/modules/feed/feed.service.spec.ts
Normal file
@@ -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<Record<string, any>> = {}) => {
|
||||
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' }));
|
||||
});
|
||||
});
|
||||
@@ -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<string, unknown> & {
|
||||
@@ -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<string, unknown>),
|
||||
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<string, any>;
|
||||
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<T extends FeedPostItem>(
|
||||
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 };
|
||||
|
||||
المرجع في مشكلة جديدة
حظر مستخدم