الملفات
back_end_oudelaa/src/modules/feed/feed.service.ts
2026-04-20 15:12:16 +03:00

213 أسطر
6.5 KiB
TypeScript

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<string, unknown> = {
$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;
}
}