Add reels feed support

هذا الالتزام موجود في:
boutmoun123
2026-07-06 15:17:53 +03:00
الأصل 830b0fdf84
التزام 1b24ca4294
4 ملفات معدلة مع 304 إضافات و18 حذوفات

عرض الملف

@@ -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')

عرض الملف

@@ -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 };