import { Injectable, Logger, NotFoundException } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import { 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 { 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 { FeedRepository } from './feed.repository'; type FeedPostItem = Record & { feedItemType: 'post'; feedScore?: number; likedByMe: boolean; savedByMe: 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>; }; @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, ) {} 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 = cacheEnabled ? await this.feedVersionService.getGlobalVersion() : 0; const includeSuggestions = this.shouldIncludeSuggestions(query); const cacheKey = this.buildCacheKey('me', { currentUserId, globalVersion, page: query.page ?? 1, limit: query.limit ?? 20, cursor: query.cursor ?? '', followingOnly, radiusKm: query.radiusKm ?? 30, preferredPostType: query.preferredPostType ?? '', includeSuggestions, suggestionInterval: query.suggestionInterval ?? 4, }); 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; } } const currentUser = await this.usersRepository.findById(currentUserId); if (!currentUser) { throw new NotFoundException('Current user not found'); } const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const page = query.page ?? 1; const radiusKm = query.radiusKm ?? 30; const skip = cursorOffset ?? (page - 1) * limit; const [followingIds, invisibleUserIds] = await Promise.all([ this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), ]); const relationLookupMs = this.markTiming(timing); let filter = this.buildVisiblePostsFilter(currentUserId, followingIds, followingOnly, invisibleUserIds); let candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); 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, invisibleUserIds); 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; }) .map((post) => ({ post, score: this.scorePost({ currentUser, currentUserId, followingIds, post, preferredPostType: query.preferredPostType, radiusKm, }), })) .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 total = scored.length; const pagedPosts = scored.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 decorationMs = this.markTiming(timing); const items = includeSuggestions ? await this.mixHomeFeedItems(currentUserId, decoratedPosts, query.suggestionInterval ?? 4) : decoratedPosts; const cardsMs = this.markTiming(timing); const nextOffset = skip + pagedPosts.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; const result = buildPaginatedResponse(items, { page, limit, total, offset: skip, currentCursor: query.cursor ?? null, nextCursor, mode: 'cursor', }); if (cacheEnabled) { await this.cacheService.set( cacheKey, result, this.configService.get('feedCache.userFeedTtlSeconds', { infer: true }) ?? 15, ); } this.logFeedTiming(timing, { cacheHit: false, page, limit, skip, followingOnly, includeSuggestions, followingCount: followingIds.length, invisibleUserCount: invisibleUserIds.length, candidateCount: candidates.length, scoredCount: scored.length, itemCount: items.length, postItemCount: decoratedPosts.length, nextCursor, relationLookupMs, firstCandidateLookupMs, fallbackCandidateLookupMs, scoringMs, decorationMs, cardsMs, responseBytes: this.measureResponseBytes(result), }); return result; } async getExplore(currentUserId: string, query: FeedQueryDto) { return this.getTrending(currentUserId, { ...query, followingOnly: false, 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 ?? '', }); 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; } } const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const page = query.page ?? 1; const skip = cursorOffset ?? (page - 1) * limit; const [followingIds, invisibleUserIds] = await Promise.all([ this.feedRepository.findFollowingIds(currentUserId), this.blocksService.getInvisibleUserIds(currentUserId), ]); const relationLookupMs = this.markTiming(timing); const trendingFilter: Record = { visibility: PostVisibility.PUBLIC }; if (invisibleUserIds.length) { trendingFilter.authorId = { $nin: invisibleUserIds.map((id) => new Types.ObjectId(id)) }; } if (query.preferredPostType) { trendingFilter.postType = query.preferredPostType; } const [rows, total] = await Promise.all([ this.feedRepository.findTrendingPublicPosts(trendingFilter, skip, limit), this.feedRepository.count(trendingFilter), ]); const postLookupMs = this.markTiming(timing); const decoratedPosts = await this.decoratePostsForViewer( currentUserId, rows.map((item) => item.toObject() as unknown as Record), followingIds, ); const decorationMs = this.markTiming(timing); const nextOffset = skip + rows.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; const result = buildPaginatedResponse(decoratedPosts, { 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, ); } this.logFeedTiming(timing, { cacheHit: false, page, limit, skip, preferredPostType: query.preferredPostType ?? '', followingCount: followingIds.length, invisibleUserCount: invisibleUserIds.length, itemCount: decoratedPosts.length, total, nextCursor, relationLookupMs, postLookupMs, decorationMs, responseBytes: this.measureResponseBytes(result), }); return result; } private async decoratePostsForViewer( currentUserId: string, items: Array>, followingIds: string[], ): Promise { const postIds = items .map((item) => this.extractEntityId(item._id ?? item.id)) .filter(Boolean); const followingSet = new Set(followingIds); const [likedPostIds, savedPostIds] = await Promise.all([ this.likesRepository.findLikedPostIds(currentUserId, postIds), this.savesRepository.findSavedPostIds(currentUserId, postIds), ]); const likedSet = new Set(likedPostIds); const savedSet = new Set(savedPostIds); return items.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), followingAuthor: !!authorId && followingSet.has(authorId), isOwnPost: authorId === currentUserId, canComment: true, canMessage: !!authorId && authorId !== currentUserId, engagement: { likesCount, commentsCount, savesCount, shareCount, viewCount, playCount, }, }; }); } 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?: PostType; radiusKm: number; }): number { const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input; const author = post.authorId; const authorId = this.extractEntityId(author); const isOwnPost = authorId === currentUserId; const isFollowing = followingIds.includes(authorId); const ageMs = Date.now() - new Date(post.createdAt).getTime(); const ageHours = ageMs / (1000 * 60 * 60); const freshness = Math.max(0, 36 - ageHours); const engagement = Number(post.likesCount ?? 0) * 3 + Number(post.commentsCount ?? 0) * 4 + Number(post.savesCount ?? 0) * 5 + Number(post.shareCount ?? 0) * 6 + Number(post.viewCount ?? 0) * 0.15 + Number(post.playCount ?? 0) * 0.25; const hashtagMatches = this.intersectionCount( this.buildPreferenceTokens(currentUser), (post.hashtags ?? []).map((value: string) => value.toLowerCase()), ); const distanceKm = this.computeDistanceKm( currentUser.latitude, currentUser.longitude, author?.latitude ?? null, author?.longitude ?? null, ); const nearbyBoost = typeof distanceKm === 'number' && distanceKm <= radiusKm ? Math.max(0, 25 - distanceKm / 2) : 0; let score = 0; score += engagement; score += freshness; score += isOwnPost ? 10 : 0; score += isFollowing ? 40 : 0; score += post.postType === preferredPostType ? 18 : 0; score += hashtagMatches * 9; score += nearbyBoost; score += author?.isVerified ? 8 : 0; score += Math.min(20, Math.floor((author?.followersCount ?? 0) / 200)); 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 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; } } }