import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { InjectConnection } from '@nestjs/mongoose'; import { Connection, Types } from 'mongoose'; import { performance } from 'perf_hooks'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; import { buildPaginatedResponse } from '../../common/utils/pagination.util'; import { AppCacheService } from '../../infrastructure/cache/app-cache.service'; import { FeedVersionService } from '../../infrastructure/cache/feed-version.service'; import { BlocksService } from '../blocks/blocks.service'; import { FollowsService } from '../follows/follows.service'; import { LikesRepository } from '../likes/likes.repository'; import { MarketplaceService } from '../marketplace/marketplace.service'; 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 { FeedPostTypeFilter, FeedQueryDto } from './dto/feed-query.dto'; import { FeedRepository } from './feed.repository'; type FeedPostItem = Record & { feedItemType: 'post'; feedScore?: number; likedByMe: boolean; savedByMe: boolean; isSharedByMe: boolean; followingAuthor: boolean; isOwnPost: boolean; canComment: boolean; canMessage: boolean; engagement: { likesCount: number; commentsCount: number; savesCount: number; shareCount: number; viewCount: number; playCount: number; }; }; type FeedCardItem = | { id: string; feedItemType: 'suggested_users'; title: string; subtitle: string; items: Array>; } | { id: string; feedItemType: 'featured_marketplace'; title: string; subtitle: string; listings: Array>; musicalInstruments: Array>; instruments: Array>; repairShops: Array>; }; type ViewerRankingProfile = { authorAffinity: Record; postTypeAffinity: Record; hashtagAffinity: Record; interactedPostIds: string[]; engagementSignals: Record; }; type RankedPost = { post: Record & { toObject(): unknown }; score: number; }; @Injectable() export class FeedService { private readonly logger = new Logger(FeedService.name); constructor( private readonly feedRepository: FeedRepository, private readonly usersRepository: UsersRepository, private readonly cacheService: AppCacheService, private readonly feedVersionService: FeedVersionService, private readonly configService: ConfigService, private readonly likesRepository: LikesRepository, private readonly savesRepository: SavesRepository, private readonly followsService: FollowsService, private readonly marketplaceService: MarketplaceService, private readonly blocksService: BlocksService, @InjectConnection() private readonly connection: Connection, ) {} async getMyFeed(currentUserId: string, query: FeedQueryDto) { const timing = this.startTiming('feed.me'); const followingOnly = query.followingOnly ?? true; const cacheEnabled = this.configService.get('feedCache.enabled', { infer: true }) ?? true; const [globalVersion, userVersion] = cacheEnabled ? await Promise.all([ this.feedVersionService.getGlobalVersion(), this.feedVersionService.getUserVersion(currentUserId), ]) : [0, 0]; const includeSuggestions = this.shouldIncludeSuggestions(query); const cacheKey = this.buildCacheKey('me', { currentUserId, globalVersion, userVersion, page: query.page ?? 1, limit: query.limit ?? 20, cursor: query.cursor ?? '', followingOnly, radiusKm: query.radiusKm ?? 30, preferredPostType: query.preferredPostType ?? '', includeSuggestions, suggestionInterval: query.suggestionInterval ?? 4, }); let cacheFillLock: Awaited> | null = null; if (cacheEnabled) { const cached = await this.cacheService.get>(cacheKey); if (cached) { this.logFeedTiming(timing, { cacheHit: true, itemCount: Array.isArray(cached.items) ? cached.items.length : undefined, nextCursor: cached.nextCursor ?? null, responseBytes: this.measureResponseBytes(cached), }); return cached; } cacheFillLock = await this.cacheService.acquireFillLock(cacheKey, 10); if (!cacheFillLock.acquired) { const coalesced = await this.cacheService.waitForValue>( cacheKey, 2_000, ); if (coalesced) { this.logFeedTiming(timing, { cacheHit: true, cacheCoalesced: true, itemCount: Array.isArray(coalesced.items) ? coalesced.items.length : undefined, responseBytes: this.measureResponseBytes(coalesced), }); return coalesced; } } } const limit = query.limit ?? 20; const cursorState = this.decodeFeedCursor(query.cursor); const cursorOffset = cursorState.offset; const rankedAt = cursorState.rankedAt; const page = query.page ?? 1; const radiusKm = query.radiusKm ?? 30; const skip = cursorOffset ?? (page - 1) * limit; const requestedPostType = this.resolveRequestedPostType(query.preferredPostType); const [currentUser, followingIds, invisibleUserIds, reportedPostIds, notInterestedPostIds, rankingProfile] = await Promise.all([ this.usersRepository.findById(currentUserId), this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), this.findReportedPostIds(currentUserId), this.findNotInterestedPostIds(currentUserId), this.getViewerRankingProfile(currentUserId), ]); if (!currentUser) { throw new NotFoundException('Current user not found'); } const unauthorizedPrivateAuthorIds = await this.usersRepository.findPrivateUserIds([ currentUserId, ...followingIds, ]); const hiddenAuthorIds = Array.from( new Set([...invisibleUserIds, ...unauthorizedPrivateAuthorIds]), ); const relationLookupMs = this.markTiming(timing); let filter = this.buildVisiblePostsFilter( currentUserId, followingIds, followingOnly, hiddenAuthorIds, ); if (requestedPostType) { filter = { $and: [filter, { postType: requestedPostType }] }; } filter = this.addRankingWindowFilter(filter, rankedAt, [...reportedPostIds, ...notInterestedPostIds]); const candidateLimit = Math.min(1000, Math.max(limit * 20, 400)); let candidates = await this.findRankableCandidates(filter, candidateLimit); const firstCandidateLookupMs = this.markTiming(timing); // Keep the default home feed focused on followed accounts, but avoid an empty screen // for new users or when followed accounts have not posted yet. if (candidates.length === 0 && typeof query.followingOnly === 'undefined' && followingOnly) { filter = this.buildVisiblePostsFilter(currentUserId, followingIds, false, hiddenAuthorIds); if (requestedPostType) { filter = { $and: [filter, { postType: requestedPostType }] }; } filter = this.addRankingWindowFilter(filter, rankedAt, [...reportedPostIds, ...notInterestedPostIds]); candidates = await this.findRankableCandidates(filter, candidateLimit); } const fallbackCandidateLookupMs = this.markTiming(timing); const activeAuthorCandidates = candidates.filter((post) => { const author = post.authorId as unknown as { isDisabled?: boolean } | null; return !!author && author.isDisabled !== true; }); const filteredCandidates = requestedPostType ? activeAuthorCandidates.filter((post) => post.postType === requestedPostType) : activeAuthorCandidates; const scored = filteredCandidates .map((post) => ({ post, score: this.scorePost({ currentUser, currentUserId, followingIds, post, preferredPostType: query.preferredPostType, radiusKm, rankingProfile, rankedAt, }), })) .sort( (a, b) => b.score - a.score || new Date((b.post as any).createdAt ?? 0).getTime() - new Date((a.post as any).createdAt ?? 0).getTime(), ); const scoringMs = this.markTiming(timing); const diversified = this.diversifyRankedPosts(scored); const total = diversified.length; const pagedPosts = diversified.slice(skip, skip + limit).map((entry) => ({ ...(entry.post.toObject() as unknown as Record), 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, normalizedPosts, query.suggestionInterval ?? 4) : normalizedPosts; const cardsMs = this.markTiming(timing); const nextOffset = skip + pagedPosts.length; const nextCursor = nextOffset < total ? this.encodeFeedCursor(nextOffset, rankedAt) : null; const result = buildPaginatedResponse(items, { page, limit, total, offset: skip, currentCursor: query.cursor ?? null, nextCursor, mode: 'cursor', }); result.pagination.hasNextPage = nextCursor !== null; result.pagination.nextPage = nextCursor !== null ? page + 1 : null; if (cacheEnabled) { await this.cacheService.set( cacheKey, result, this.configService.get('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15, ); } if (cacheFillLock?.acquired) { await cacheFillLock.release().catch(() => false); } this.logFeedTiming(timing, { cacheHit: false, page, limit, skip, followingOnly, includeSuggestions, followingCount: followingIds.length, invisibleUserCount: invisibleUserIds.length, candidateCount: candidates.length, filteredCandidateCount: filteredCandidates.length, scoredCount: scored.length, itemCount: items.length, postItemCount: normalizedPosts.length, nextCursor, relationLookupMs, firstCandidateLookupMs, fallbackCandidateLookupMs, scoringMs, decorationMs, cardsMs, responseBytes: this.measureResponseBytes(result), }); return result; } private async findRankableCandidates( filter: Record, limit: number, ): Promise & { toObject(): unknown }>> { const recentLimit = Math.ceil(limit * 0.65); const popularLimit = limit - recentLimit; const [recent, popular] = await Promise.all([ this.feedRepository.findCandidatePosts(filter, recentLimit), this.feedRepository.findPopularCandidatePosts(filter, popularLimit), ]); const unique = new Map & { toObject(): unknown }>(); for (const post of [...recent, ...popular]) { const id = this.extractEntityId((post as any)._id ?? (post as any).id); if (id && !unique.has(id)) { unique.set(id, post as unknown as Record & { toObject(): unknown }); } } return Array.from(unique.values()); } private addRankingWindowFilter( filter: Record, rankedAt: Date, reportedPostIds: Types.ObjectId[], ): Record { const clauses: Record[] = [filter, { createdAt: { $lte: rankedAt } }]; if (reportedPostIds.length) { clauses.push({ _id: { $nin: reportedPostIds } }); } return { $and: clauses }; } private async findReportedPostIds(currentUserId: string): Promise { if (!Types.ObjectId.isValid(currentUserId)) { return []; } const rows = await this.connection .collection('reports') .find({ reporterId: new Types.ObjectId(currentUserId), targetType: 'post', status: { $ne: 'rejected' }, }) .project({ targetId: 1 }) .toArray(); return rows .map((row) => row.targetId) .filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); } private async findNotInterestedPostIds(currentUserId: string): Promise { const rows = await this.connection .collection<{ postId?: Types.ObjectId }>('postpreferences') .find({ userId: new Types.ObjectId(currentUserId), notInterested: true }) .project({ postId: 1 }) .toArray(); return rows.map((row) => row.postId).filter((id): id is Types.ObjectId => id instanceof Types.ObjectId); } private async getViewerRankingProfile(currentUserId: string): Promise { const empty: ViewerRankingProfile = { authorAffinity: {}, postTypeAffinity: {}, hashtagAffinity: {}, interactedPostIds: [], engagementSignals: {}, }; if (!Types.ObjectId.isValid(currentUserId)) { return empty; } const cacheEnabled = this.configService.get('feedCache.enabled', { infer: true }) ?? true; const cacheKey = `feed:ranking-profile:${currentUserId}`; if (cacheEnabled) { const cached = await this.cacheService.get(cacheKey); if (cached) { return cached; } } const userId = new Types.ObjectId(currentUserId); const cutoff = new Date(Date.now() - 90 * 24 * 60 * 60 * 1000); const [likes, saves, shares, comments, engagementRows] = await Promise.all([ this.connection .collection('likes') .find({ userId, targetType: 'post', createdAt: { $gte: cutoff } }) .sort({ createdAt: -1 }) .limit(250) .project({ targetId: 1 }) .toArray(), this.connection .collection('saves') .find({ userId, createdAt: { $gte: cutoff } }) .sort({ createdAt: -1 }) .limit(250) .project({ postId: 1 }) .toArray(), this.connection .collection('postshares') .find({ userId, isDeleted: false, createdAt: { $gte: cutoff } }) .sort({ createdAt: -1 }) .limit(250) .project({ postId: 1 }) .toArray(), this.connection .collection('comments') .find({ authorId: userId, isDeleted: { $ne: true }, createdAt: { $gte: cutoff } }) .sort({ createdAt: -1 }) .limit(250) .project({ postId: 1 }) .toArray(), this.connection.collection('engagementevents').aggregate<{ _id: Types.ObjectId; watchMs: number; completions: number; rewatches: number; profileOpens: number; }>([ { $match: { userId, occurredAt: { $gte: cutoff } } }, { $group: { _id: '$postId', watchMs: { $sum: '$watchTimeMs' }, completions: { $sum: { $cond: [{ $eq: ['$type', 'complete'] }, 1, 0] } }, rewatches: { $sum: { $cond: [{ $eq: ['$type', 'rewatch'] }, 1, 0] } }, profileOpens: { $sum: { $cond: [{ $eq: ['$type', 'profile_open'] }, 1, 0] } }, } }, { $limit: 500 }, ]).toArray(), ]); const weights = new Map(); const addWeight = (value: unknown, weight: number) => { if (value instanceof Types.ObjectId) { const id = value.toString(); weights.set(id, (weights.get(id) ?? 0) + weight); } }; likes.forEach((row) => addWeight(row.targetId, 1.5)); saves.forEach((row) => addWeight(row.postId, 4)); shares.forEach((row) => addWeight(row.postId, 5)); comments.forEach((row) => addWeight(row.postId, 3)); engagementRows.forEach((row) => addWeight(row._id, Math.min(12, row.watchMs / 60_000) + row.completions * 4 + row.rewatches * 5 + row.profileOpens * 2), ); if (!weights.size) { return empty; } const postIds = Array.from(weights.keys()).map((id) => new Types.ObjectId(id)); const posts = await this.connection .collection('posts') .find({ _id: { $in: postIds }, isDeleted: { $ne: true } }) .project({ authorId: 1, postType: 1, hashtags: 1 }) .toArray(); const profile: ViewerRankingProfile = { authorAffinity: {}, postTypeAffinity: {}, hashtagAffinity: {}, interactedPostIds: Array.from(weights.keys()), engagementSignals: Object.fromEntries(engagementRows.map((row) => [row._id.toString(), row])), }; for (const post of posts) { const postWeight = weights.get(post._id.toString()) ?? 0; const authorId = post.authorId?.toString?.() ?? ''; const postType = typeof post.postType === 'string' ? post.postType : ''; if (authorId) { profile.authorAffinity[authorId] = (profile.authorAffinity[authorId] ?? 0) + postWeight; } if (postType) { profile.postTypeAffinity[postType] = (profile.postTypeAffinity[postType] ?? 0) + postWeight; } for (const hashtag of Array.isArray(post.hashtags) ? post.hashtags : []) { const normalized = String(hashtag).trim().toLowerCase(); if (normalized) { profile.hashtagAffinity[normalized] = (profile.hashtagAffinity[normalized] ?? 0) + postWeight; } } } if (cacheEnabled) { await this.cacheService.set( cacheKey, profile, this.configService.get('feedCache.rankingProfileTtlSeconds', { infer: true, }) ?? 30, ); } return profile; } private diversifyRankedPosts(ranked: RankedPost[]): RankedPost[] { const remaining = [...ranked]; const result: RankedPost[] = []; const authorCounts = new Map(); const typeCounts = new Map(); const hashtagCounts = new Map(); while (remaining.length) { let bestIndex = 0; let bestAdjustedScore = Number.NEGATIVE_INFINITY; for (let index = 0; index < remaining.length; index += 1) { const entry = remaining[index]; const authorId = this.extractEntityId(entry.post.authorId); const postType = String(entry.post.postType ?? ''); const hashtags = Array.isArray(entry.post.hashtags) ? entry.post.hashtags.map((tag: unknown) => String(tag).toLowerCase()) : []; const previousAuthorId = result.length ? this.extractEntityId(result[result.length - 1].post.authorId) : ''; const authorPenalty = (authorCounts.get(authorId) ?? 0) * 12; const consecutivePenalty = authorId && authorId === previousAuthorId ? 22 : 0; const typePenalty = Math.max(0, (typeCounts.get(postType) ?? 0) - 1) * 2.5; const hashtagPenalty = Math.min( 8, hashtags.reduce((sum, tag) => sum + (hashtagCounts.get(tag) ?? 0) * 0.75, 0), ); const adjusted = entry.score - authorPenalty - consecutivePenalty - typePenalty - hashtagPenalty; if (adjusted > bestAdjustedScore) { bestAdjustedScore = adjusted; bestIndex = index; } } const [selected] = remaining.splice(bestIndex, 1); const authorId = this.extractEntityId(selected.post.authorId); const postType = String(selected.post.postType ?? ''); authorCounts.set(authorId, (authorCounts.get(authorId) ?? 0) + 1); typeCounts.set(postType, (typeCounts.get(postType) ?? 0) + 1); for (const hashtag of Array.isArray(selected.post.hashtags) ? selected.post.hashtags : []) { const normalized = String(hashtag).toLowerCase(); hashtagCounts.set(normalized, (hashtagCounts.get(normalized) ?? 0) + 1); } result.push({ ...selected, score: bestAdjustedScore }); } return result; } private decodeFeedCursor(cursor?: string): { offset: number | null; rankedAt: Date } { const fallback = { offset: decodeOffsetCursor(cursor), rankedAt: new Date() }; if (!cursor) { return fallback; } try { const payload = JSON.parse(Buffer.from(cursor, 'base64url').toString('utf8')) as { offset?: unknown; rankedAt?: unknown; }; const rankedAt = new Date(String(payload.rankedAt ?? '')); if ( !Number.isInteger(payload.offset) || Number(payload.offset) < 0 || Number.isNaN(rankedAt.getTime()) ) { return fallback; } return { offset: Number(payload.offset), rankedAt }; } catch { return fallback; } } private encodeFeedCursor(offset: number, rankedAt: Date): string { return Buffer.from( JSON.stringify({ version: 1, offset, rankedAt: rankedAt.toISOString() }), 'utf8', ).toString('base64url'); } async getExplore(currentUserId: string, query: FeedQueryDto) { return this.getTrending(currentUserId, { ...query, followingOnly: false, includeSuggestions: false, }); } 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 = this.configService.get('feedCache.enabled', { infer: true }) ?? true; const globalVersion = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0; const cacheKey = this.buildCacheKey('trending', { currentUserId, globalVersion, page: query.page ?? 1, limit: query.limit ?? 20, cursor: query.cursor ?? '', preferredPostType: query.preferredPostType ?? '', }); let cacheFillLock: Awaited> | null = null; if (cacheEnabled) { const cached = await this.cacheService.get>(cacheKey); if (cached) { this.logFeedTiming(timing, { cacheHit: true, itemCount: Array.isArray(cached.items) ? cached.items.length : undefined, nextCursor: cached.nextCursor ?? null, responseBytes: this.measureResponseBytes(cached), }); return cached; } cacheFillLock = await this.cacheService.acquireFillLock(cacheKey, 10); if (!cacheFillLock.acquired) { const coalesced = await this.cacheService.waitForValue>( cacheKey, 2_000, ); if (coalesced) { this.logFeedTiming(timing, { cacheHit: true, cacheCoalesced: true, itemCount: Array.isArray(coalesced.items) ? coalesced.items.length : undefined, responseBytes: this.measureResponseBytes(coalesced), }); return coalesced; } } } const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const page = query.page ?? 1; const skip = cursorOffset ?? (page - 1) * limit; const [followingIds, invisibleUserIds, privateAuthorIds] = await Promise.all([ this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), this.usersRepository.findPrivateUserIds(), ]); const relationLookupMs = this.markTiming(timing); const trendingFilter: Record = { visibility: PostVisibility.PUBLIC }; const excludedAuthorIds = Array.from(new Set([...invisibleUserIds, ...privateAuthorIds])); if (excludedAuthorIds.length) { trendingFilter.authorId = { $nin: excludedAuthorIds.map((id) => new Types.ObjectId(id)) }; } const requestedPostType = this.resolveRequestedPostType(query.preferredPostType); if (requestedPostType) { trendingFilter.postType = requestedPostType; } const [rows, total] = await Promise.all([ this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit), this.feedRepository.count(trendingFilter), ]); const postLookupMs = this.markTiming(timing); const visibleRows = rows.filter((post) => { const author = post.authorId as unknown as { isDisabled?: boolean } | null; return !!author && author.isDisabled !== true; }); const decoratedPosts = await this.decoratePostsForViewer( currentUserId, visibleRows.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(normalizedPosts, { page, limit, total, offset: skip, currentCursor: query.cursor ?? null, nextCursor, mode: 'cursor', }); if (cacheEnabled) { await this.cacheService.set( cacheKey, result, this.configService.get('feedCache.trendingTtlSeconds', { infer: true }) ?? 30, ); } if (cacheFillLock?.acquired) { await cacheFillLock.release().catch(() => false); } this.logFeedTiming(timing, { cacheHit: false, page, limit, skip, preferredPostType: query.preferredPostType ?? '', followingCount: followingIds.length, invisibleUserCount: invisibleUserIds.length, itemCount: normalizedPosts.length, total, nextCursor, relationLookupMs, postLookupMs, decorationMs, responseBytes: this.measureResponseBytes(result), }); return result; } private async decoratePostsForViewer( currentUserId: string, items: Array>, followingIds: string[], ): Promise { const safeItems = await this.redactInaccessibleOriginalReferences( currentUserId, items, followingIds, ); const postIds = safeItems.map((item) => this.extractEntityId(item._id ?? item.id)).filter(Boolean); const followingSet = new Set(followingIds); const [likedPostIds, savedPostIds, sharedPostIds] = await Promise.all([ this.likesRepository.findLikedPostIds(currentUserId, postIds), this.savesRepository.findSavedPostIds(currentUserId, postIds), this.findSharedPostIds(currentUserId, postIds), ]); const likedSet = new Set(likedPostIds); const savedSet = new Set(savedPostIds); const sharedSet = new Set(sharedPostIds); return safeItems.map((item) => { const postId = this.extractEntityId(item._id ?? item.id); const authorId = this.extractEntityId(item.authorId); const likesCount = Number(item.likesCount ?? 0); const commentsCount = Number(item.commentsCount ?? 0); const savesCount = Number(item.savesCount ?? 0); const shareCount = Number(item.shareCount ?? 0); const viewCount = Number(item.viewCount ?? 0); const playCount = Number(item.playCount ?? 0); return { ...item, id: postId, feedItemType: 'post', likedByMe: likedSet.has(postId), savedByMe: savedSet.has(postId), isSharedByMe: sharedSet.has(postId), followingAuthor: !!authorId && followingSet.has(authorId), isOwnPost: authorId === currentUserId, canComment: true, canMessage: !!authorId && authorId !== currentUserId, engagement: { likesCount, commentsCount, savesCount, shareCount, viewCount, playCount, }, }; }); } private async redactInaccessibleOriginalReferences( currentUserId: string, items: Array>, followingIds: string[], ): Promise>> { const fields = ['repostOfPostId', 'quoteOfPostId'] as const; const sourceIds = Array.from( new Set( items .flatMap((item) => fields.map((field) => this.extractEntityId(item[field]))) .filter((id) => Types.ObjectId.isValid(id)), ), ); if (!sourceIds.length) { return items; } const [sources, unauthorizedPrivateAuthorIds, invisibleUserIds] = await Promise.all([ this.connection .collection('posts') .find({ _id: { $in: sourceIds.map((id) => new Types.ObjectId(id)) }, isDeleted: { $ne: true }, isArchived: { $ne: true }, moderationStatus: { $ne: 'hidden' }, }) .project({ authorId: 1, visibility: 1 }) .toArray(), this.usersRepository.findPrivateUserIds([currentUserId, ...followingIds]), this.blocksService.getInvisibleUserIds(currentUserId), ]); const sourceById = new Map(sources.map((source) => [source._id.toString(), source])); const followingSet = new Set(followingIds); const excludedAuthorSet = new Set([ ...unauthorizedPrivateAuthorIds, ...invisibleUserIds, ]); return items.map((item) => { const safe = { ...item }; for (const field of fields) { const sourceId = this.extractEntityId(item[field]); if (!sourceId) continue; const source = sourceById.get(sourceId); const authorId = this.extractEntityId(source?.authorId); const allowed = !!source && !!authorId && !excludedAuthorSet.has(authorId) && (authorId === currentUserId || source.visibility === PostVisibility.PUBLIC || (source.visibility === PostVisibility.FOLLOWERS && followingSet.has(authorId))); if (!allowed) { safe[field] = null; } } return safe; }); } private async findSharedPostIds(currentUserId: string, postIds: string[]): Promise { if (!Types.ObjectId.isValid(currentUserId) || postIds.length === 0) { return []; } const validPostIds = Array.from(new Set(postIds.filter((id) => Types.ObjectId.isValid(id)))); if (!validPostIds.length) { return []; } const rows = await this.connection .collection<{ postId: Types.ObjectId }>('postshares') .find({ userId: new Types.ObjectId(currentUserId), postId: { $in: validPostIds.map((id) => new Types.ObjectId(id)) }, target: PostShareTarget.PROFILE, isDeleted: false, deletedAt: null, }) .project({ postId: 1 }) .toArray(); return rows.map((row) => row.postId.toString()); } private async mixHomeFeedItems( currentUserId: string, posts: FeedPostItem[], suggestionInterval: number, ): Promise> { const cards = await this.buildHomeCards(currentUserId); if (!cards.length) { return posts; } const result: Array = []; let cardIndex = 0; for (let index = 0; index < posts.length; index += 1) { result.push(posts[index]); if ((index + 1) % suggestionInterval === 0 && cardIndex < cards.length) { result.push(cards[cardIndex]); cardIndex += 1; } } while (cardIndex < cards.length) { result.push(cards[cardIndex]); cardIndex += 1; } return result; } private async buildHomeCards(currentUserId: string): Promise { const [suggestions, listings, instruments, repairShops] = await Promise.all([ this.followsService.getSuggestions(currentUserId, { page: 1, limit: 5, }), this.marketplaceService.getPublicListings({ page: 1, limit: 3, isActive: true, } as any), this.marketplaceService.getPublicInstruments({ page: 1, limit: 3, isActive: true, } as any), this.marketplaceService.getPublicRepairShops({ page: 1, limit: 2, isActive: true, } as any), ]); const cards: FeedCardItem[] = []; if (Array.isArray(suggestions.items) && suggestions.items.length > 0) { cards.push({ id: `suggested-users:${currentUserId}`, feedItemType: 'suggested_users', title: 'Suggested creators', subtitle: 'People you may want to follow', items: suggestions.items.map((entry) => ({ ...entry, following: false, })), }); } if ( (listings.items?.length ?? 0) > 0 || (instruments.items?.length ?? 0) > 0 || (repairShops.items?.length ?? 0) > 0 ) { cards.push({ id: `featured-marketplace:${currentUserId}`, feedItemType: 'featured_marketplace', title: 'Explore marketplace', subtitle: 'Featured listings, musical instruments, and repair shops', listings: (listings.items ?? []) as unknown as Array>, musicalInstruments: (instruments.items ?? []) as unknown as Array>, instruments: (instruments.items ?? []) as unknown as Array>, repairShops: (repairShops.items ?? []) as unknown as Array>, }); } return cards; } private buildVisiblePostsFilter( currentUserId: string, followingIds: string[], followingOnly: boolean, invisibleUserIds: string[] = [], ): Record { const currentUserObjectId = new Types.ObjectId(currentUserId); const followingObjectIds = followingIds.map((id) => new Types.ObjectId(id)); const visibilityFilter = followingOnly ? { $or: [ { authorId: currentUserObjectId }, { authorId: { $in: followingObjectIds }, visibility: { $in: [PostVisibility.PUBLIC, PostVisibility.FOLLOWERS] }, }, ], } : { $or: [ { visibility: PostVisibility.PUBLIC }, { authorId: currentUserObjectId }, { authorId: { $in: followingObjectIds }, visibility: PostVisibility.FOLLOWERS, }, ], }; if (!invisibleUserIds.length) { return visibilityFilter; } return { $and: [ visibilityFilter, { authorId: { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) } }, ], }; } private scorePost(input: { currentUser: UserDocument; currentUserId: string; followingIds: string[]; post: Record; preferredPostType?: FeedPostTypeFilter; radiusKm: number; rankingProfile: ViewerRankingProfile; rankedAt: Date; }): number { const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm, rankingProfile, rankedAt, } = input; const author = post.authorId; const authorId = this.extractEntityId(author); const isOwnPost = authorId === currentUserId; const isFollowing = followingIds.includes(authorId); const ageMs = Math.max(0, rankedAt.getTime() - new Date(post.createdAt).getTime()); const ageHours = ageMs / (1000 * 60 * 60); const freshness = 32 * Math.exp(-ageHours / (24 * 3)); const veryRecentBoost = ageHours <= 6 ? 5 : 0; const engagement = Math.log1p(Math.max(0, Number(post.likesCount ?? 0))) * 2.4 + Math.log1p(Math.max(0, Number(post.commentsCount ?? 0))) * 3.2 + Math.log1p(Math.max(0, Number(post.savesCount ?? 0))) * 4.2 + Math.log1p(Math.max(0, Number(post.shareCount ?? 0))) * 4.8 + Math.log1p(Math.max(0, Number(post.viewCount ?? 0))) * 0.7 + Math.log1p(Math.max(0, Number(post.playCount ?? 0))) * 1.1; const normalizedHashtags = (post.hashtags ?? []) .map((value: string) => value.trim().toLowerCase()) .filter(Boolean); const hashtagMatches = this.intersectionCount( this.buildPreferenceTokens(currentUser), normalizedHashtags, ); const behaviorHashtagAffinity = normalizedHashtags.reduce( (sum: number, hashtag: string) => sum + (rankingProfile.hashtagAffinity[hashtag] ?? 0), 0, ); const authorAffinity = rankingProfile.authorAffinity[authorId] ?? 0; const postTypeAffinity = rankingProfile.postTypeAffinity[String(post.postType ?? '')] ?? 0; const interactedBefore = rankingProfile.interactedPostIds.includes( this.extractEntityId(post._id ?? post.id), ); const behavior = (rankingProfile.engagementSignals ?? {})[this.extractEntityId(post._id ?? post.id)]; const watchQualityBoost = behavior ? Math.min(18, Math.log1p(Math.max(0, behavior.watchMs) / 1000) * 2) + Math.min(12, behavior.completions * 4) + Math.min(12, behavior.rewatches * 6) + Math.min(6, behavior.profileOpens * 2) : 0; const distanceKm = this.computeDistanceKm( currentUser.latitude, currentUser.longitude, author?.latitude ?? null, author?.longitude ?? null, ); const nearbyBoost = typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 8 - (distanceKm / radiusKm) * 8) : 0; let score = 0; score += engagement; score += freshness; score += veryRecentBoost; score += isOwnPost ? 3 : 0; score += isFollowing ? 24 : 0; score += post.postType === preferredPostType ? 12 : 0; score += hashtagMatches * 7; score += Math.min(24, Math.log1p(authorAffinity) * 7); score += Math.min(14, Math.log1p(postTypeAffinity) * 4); score += Math.min(16, Math.log1p(behaviorHashtagAffinity) * 4); score += watchQualityBoost; score += nearbyBoost; score += author?.isVerified ? 2.5 : 0; score += Math.min(6, Math.log1p(Math.max(0, Number(author?.followersCount ?? 0)))); score -= interactedBefore ? 10 : 0; return score; } private buildPreferenceTokens(user: UserDocument): string[] { const tokens = [ ...(user.musicGenres ?? []), ...(user.favoriteInstruments ?? []), ...(user.favoriteMaqamat ?? []), ...(user.musicRoles ?? []), ] .map((item) => item.trim().toLowerCase()) .filter(Boolean); return Array.from(new Set(tokens)); } private intersectionCount(a: string[], b: string[]): number { if (!a.length || !b.length) { return 0; } const right = new Set(b); let count = 0; for (const item of a) { if (right.has(item)) { count += 1; } } return count; } private computeDistanceKm( lat1: number | null | undefined, lon1: number | null | undefined, lat2: number | null | undefined, lon2: number | null | undefined, ): number | null { if ( typeof lat1 !== 'number' || typeof lon1 !== 'number' || typeof lat2 !== 'number' || typeof lon2 !== 'number' ) { return null; } const toRad = (deg: number) => (deg * Math.PI) / 180; const earthKm = 6371; const dLat = toRad(lat2 - lat1); const dLon = toRad(lon2 - lon1); const a = Math.sin(dLat / 2) ** 2 + Math.cos(toRad(lat1)) * Math.cos(toRad(lat2)) * Math.sin(dLon / 2) ** 2; const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); return earthKm * c; } private buildCacheKey(scope: string, input: Record): string { return `feed:${scope}:${JSON.stringify(input)}`; } private shouldIncludeSuggestions(query: FeedQueryDto): boolean { return ( query.includeSuggestions === true && !(query.cursor ?? '').trim() && (query.page ?? 1) === 1 ); } private extractEntityId(value: unknown): string { if (!value) { return ''; } if (typeof value === 'string') { return value; } if (value instanceof Types.ObjectId) { return value.toString(); } if (typeof value === 'object') { const candidate = value as { _id?: unknown; id?: unknown }; if (candidate._id instanceof Types.ObjectId) { return candidate._id.toString(); } if (typeof candidate._id === 'string') { return candidate._id; } if (typeof candidate.id === 'string') { return candidate.id; } } 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 }; } private markTiming(timing: { lastAt: number }): number { const now = performance.now(); const elapsed = now - timing.lastAt; timing.lastAt = now; return Math.round(elapsed); } private logFeedTiming( timing: { scope: string; startedAt: number }, details: Record, ): void { if (!this.configService.get('performance.feedTimingLogsEnabled', { infer: true })) { return; } this.logger.log({ event: 'feed_timing', scope: timing.scope, totalMs: Math.round(performance.now() - timing.startedAt), ...details, }); } private measureResponseBytes(value: unknown): number { try { return Buffer.byteLength(JSON.stringify(value), 'utf8'); } catch { return 0; } } }