diff --git a/src/constants/variables.ts b/src/constants/variables.ts index 5d4515e4..1fe5dde8 100644 --- a/src/constants/variables.ts +++ b/src/constants/variables.ts @@ -20,6 +20,7 @@ export const ALLOWED_IMAGE_MIME_TYPES = [ 'image/tiff', 'image/svg+xml', 'image/x-icon', + 'image/heic', ] as const; export const MAX_IMAGE_FILE_SIZE = 5 * 1024 * 1024; // 5MB diff --git a/src/explore/who-to-follow.service.spec.ts b/src/explore/who-to-follow.service.spec.ts index 89cdf5ee..7bb43161 100644 --- a/src/explore/who-to-follow.service.spec.ts +++ b/src/explore/who-to-follow.service.spec.ts @@ -60,6 +60,7 @@ describe('WhoToFollowService', () => { const mock_query_builder = { select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), @@ -97,6 +98,7 @@ describe('WhoToFollowService', () => { const mock_query_builder = { select: jest.fn().mockReturnThis(), where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), orderBy: jest.fn().mockReturnThis(), addOrderBy: jest.fn().mockReturnThis(), limit: jest.fn().mockReturnThis(), @@ -187,6 +189,89 @@ describe('WhoToFollowService', () => { expect(user_repository.query).toHaveBeenCalledTimes(5); // 5 sources }); + it('should exclude followed users from popular users backfill', async () => { + const user_id = 'current-user-123'; + + // Mock minimal responses from all sources (only 1 user) + jest.spyOn(user_repository, 'query') + .mockResolvedValueOnce([{ user_id: 'user-1', mutual_count: 1 }]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]) + .mockResolvedValueOnce([]); + + const mock_recommended_users = [ + { + user_id: 'user-1', + user_username: 'user1', + user_name: 'User 1', + user_bio: '', + user_avatar_url: '', + user_verified: false, + user_followers: 10, + user_following: 5, + is_following: false, + is_followed: false, + }, + ]; + + const mock_popular_users = [ + { + id: 'popular-1', + username: 'popular1', + name: 'Popular User 1', + bio: '', + avatar_url: '', + verified: true, + followers: 10000, + following: 100, + }, + { + id: 'popular-2', + username: 'popular2', + name: 'Popular User 2', + bio: '', + avatar_url: '', + verified: false, + followers: 5000, + following: 200, + }, + ]; + + const mock_query_builder = { + select: jest.fn().mockReturnThis(), + where: jest.fn().mockReturnThis(), + andWhere: jest.fn().mockReturnThis(), + addSelect: jest.fn().mockReturnThis(), + orderBy: jest.fn().mockReturnThis(), + addOrderBy: jest.fn().mockReturnThis(), + setParameter: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getRawMany: jest.fn().mockResolvedValue(mock_recommended_users), + getMany: jest.fn().mockResolvedValue(mock_popular_users), + }; + + jest.spyOn(user_repository, 'createQueryBuilder').mockReturnValue( + mock_query_builder as any + ); + + const result = await service.getWhoToFollow(user_id, 5); + + // Verify that andWhere was called to filter out followed users + expect(mock_query_builder.andWhere).toHaveBeenCalledWith( + 'user.id != :current_user_id', + { current_user_id: user_id } + ); + + // Verify that andWhere was called to exclude followed users + const and_where_calls = mock_query_builder.andWhere.mock.calls; + expect(and_where_calls.length).toBeGreaterThan(1); + const follows_filter_call = and_where_calls.find((call: any[]) => + call[0].includes('user_follows') + ); + expect(follows_filter_call).toBeDefined(); + }); + it('should backfill with popular users if recommendations are insufficient', async () => { const user_id = 'current-user-123'; diff --git a/src/explore/who-to-follow.service.ts b/src/explore/who-to-follow.service.ts index ea7827fa..28a40984 100644 --- a/src/explore/who-to-follow.service.ts +++ b/src/explore/who-to-follow.service.ts @@ -4,13 +4,11 @@ import { UserRepository } from '../user/user.repository'; @Injectable() export class WhoToFollowService { private readonly CONFIG = { - // thresholds MAX_MUTUAL_CONNECTIONS_THRESHOLD: 10, MAX_LIKES_THRESHOLD: 10, MAX_REPLIES_THRESHOLD: 10, MAX_COMMON_CATEGORIES_THRESHOLD: 2, - // Distribution percentages DISTRIBUTION: { FRIENDS_OF_FRIENDS: 40, LIKES: 25, @@ -23,9 +21,7 @@ export class WhoToFollowService { CANDIDATE_MULTIPLIER: 3, }; - /* istanbul ignore start */ constructor(private readonly user_repository: UserRepository) {} - /* istanbul ignore stop */ async getWhoToFollow(current_user_id?: string, limit: number = 30) { if (!current_user_id) { @@ -34,12 +30,11 @@ export class WhoToFollowService { const recommendations = await this.getPersonalizedRecommendations(current_user_id, limit); - // If we don't have enough recommendations, fill with popular users if (recommendations.length < limit) { const needed = limit - recommendations.length; const existing_ids = new Set(recommendations.map((r) => r.id)); - const additional_users = await this.getPopularUsers(needed * 2); // Get extra to filter + const additional_users = await this.getPopularUsers(needed * 2, current_user_id); const filtered_additional = additional_users .filter((user) => !existing_ids.has(user.id)) .slice(0, needed); @@ -50,8 +45,8 @@ export class WhoToFollowService { return recommendations; } - private async getPopularUsers(limit: number) { - const users = await this.user_repository + private async getPopularUsers(limit: number, current_user_id?: string) { + let query = this.user_repository .createQueryBuilder('user') .select([ 'user.id', @@ -63,7 +58,18 @@ export class WhoToFollowService { 'user.followers', 'user.following', ]) - .where('user.deleted_at IS NULL') + .where('user.deleted_at IS NULL'); + + if (current_user_id) { + query = query.andWhere('user.id != :current_user_id', { current_user_id }).andWhere( + `user.id NOT IN ( + SELECT followed_id FROM user_follows WHERE follower_id = :current_user_id + )`, + { current_user_id } + ); + } + + const users = await query .orderBy('user.followers', 'DESC') .addOrderBy('user.verified', 'DESC') .limit(limit) @@ -97,7 +103,6 @@ export class WhoToFollowService { candidate_multiplier, }; - //queries in parallel const [ friends_of_friends, interest_based, @@ -112,14 +117,6 @@ export class WhoToFollowService { this.getFollowersNotFollowed(current_user_id, limits.followers), ]); - // console.log('\n=== WHO TO FOLLOW DEBUG ==='); - // console.log(`Friends of Friends: ${friends_of_friends.length} users`); - // console.log(`Interest-Based: ${interest_based.length} users`); - // console.log(`Liked Users: ${liked_users.length} users`); - // console.log(`Replied Users: ${replied_users.length} users`); - // console.log(`Followers Not Followed: ${followers_not_followed.length} users`); - - // Combine users from different sources with distribution-based approach const combined_users_with_metadata = this.combineByDistribution( friends_of_friends, interest_based, @@ -168,7 +165,6 @@ export class WhoToFollowService { const user_map = new Map(users.map((u) => [u.user_id, u])); - // Map with metadata and filter out missing users const users_with_scores = combined_users_with_metadata .map((metadata) => { const user = user_map.get(metadata.user_id); @@ -182,15 +178,6 @@ export class WhoToFollowService { }) .filter((u) => u !== null); - // console.log('\n=== FINAL RECOMMENDATIONS (ordered by score) ==='); - // users_with_scores.forEach((item, index) => { - // console.log( - // `${index + 1}. @${item.user.user_username} - Score: ${item.score.toFixed(2)} - Source: ${item.source} - Data:`, - // item.source_data - // ); - // }); - // console.log('=========================\n'); - return users_with_scores.map((item) => ({ id: item.user.user_id, username: item.user.user_username, @@ -325,7 +312,6 @@ export class WhoToFollowService { []; const seen = new Set(); - // Take top users from each source according to distribution const add_from_source = (users: any[], count: number) => { let added = 0; for (const user of users) { diff --git a/src/tweets/utils/file-upload.config.ts b/src/tweets/utils/file-upload.config.ts index d34bac3f..46e39979 100644 --- a/src/tweets/utils/file-upload.config.ts +++ b/src/tweets/utils/file-upload.config.ts @@ -1,11 +1,12 @@ import { BadRequestException } from '@nestjs/common'; import { ERROR_MESSAGES } from '../../constants/swagger-messages'; +import { ALLOWED_IMAGE_MIME_TYPES } from 'src/constants/variables'; // Image configuration export const image_file_filter = (req: any, file: any, callback: any) => { - const allowed_mime_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; + // const allowed_mime_types = ['image/jpeg', 'image/png', 'image/gif', 'image/webp']; - if (!allowed_mime_types.includes(file.mimetype)) { + if (!ALLOWED_IMAGE_MIME_TYPES.includes(file.mimetype)) { return callback(new BadRequestException(ERROR_MESSAGES.INVALID_FILE_TYPE), false); } callback(null, true);