import { Injectable, NotFoundException } from '@nestjs/common'; import { Types } from 'mongoose'; import { decodeOffsetCursor, encodeOffsetCursor } from '../../common/utils/cursor.util'; import { PostType } from '../../common/enums/post-type.enum'; import { PostVisibility } from '../../common/enums/post-visibility.enum'; import { UsersRepository } from '../users/users.repository'; import { UserDocument } from '../users/schemas/user.schema'; import { FeedQueryDto } from './dto/feed-query.dto'; import { FeedRepository } from './feed.repository'; @Injectable() export class FeedService { constructor( private readonly feedRepository: FeedRepository, private readonly usersRepository: UsersRepository, ) {} async getMyFeed(currentUserId: string, query: FeedQueryDto) { 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 followingOnly = query.followingOnly ?? false; const radiusKm = query.radiusKm ?? 30; const followingIds = await this.feedRepository.findFollowingIds(currentUserId); const visibleAuthorIds = followingOnly ? [currentUserId, ...followingIds] : null; const filter: Record = { $or: [ { visibility: PostVisibility.PUBLIC }, { authorId: new Types.ObjectId(currentUserId) }, ], }; if (visibleAuthorIds) { filter.authorId = { $in: visibleAuthorIds.map((id) => new Types.ObjectId(id)) }; } const candidates = await this.feedRepository.findCandidatePosts(filter, Math.max(limit * 12, 300)); 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 total = scored.length; const skip = cursorOffset ?? (page - 1) * limit; const items = scored.slice(skip, skip + limit).map((entry) => ({ ...entry.post.toObject(), feedScore: Number(entry.score.toFixed(3)), })); const nextOffset = skip + items.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; return { items, page, limit, total, totalPages: Math.ceil(total / limit) || 1, nextCursor, }; } async getTrending(query: FeedQueryDto) { const limit = query.limit ?? 20; const cursorOffset = decodeOffsetCursor(query.cursor); const page = query.page ?? 1; const skip = cursorOffset ?? (page - 1) * limit; const [items, total] = await Promise.all([ this.feedRepository.findTrendingPublicPosts(skip, limit), this.feedRepository.count({ visibility: PostVisibility.PUBLIC }), ]); const nextOffset = skip + items.length; const nextCursor = nextOffset < total ? encodeOffsetCursor(nextOffset) : null; return { items, page, limit, total, totalPages: Math.ceil(total / limit) || 1, nextCursor, }; } private scorePost(input: { currentUser: UserDocument; currentUserId: string; followingIds: string[]; post: any; preferredPostType?: PostType; radiusKm: number; }): number { const { currentUser, currentUserId, followingIds, post, preferredPostType, radiusKm } = input; const author: any = post.authorId; const authorId = typeof author === 'string' ? author : author?._id?.toString?.() ?? ''; 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 = post.likesCount * 3 + post.commentsCount * 4 + post.savesCount * 5; const hashtagMatches = this.intersectionCount( this.buildPreferenceTokens(currentUser), (post.hashtags ?? []).map((x: string) => x.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; } }