Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/constants/variables.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
85 changes: 85 additions & 0 deletions src/explore/who-to-follow.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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(),
Expand Down Expand Up @@ -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';

Expand Down
44 changes: 15 additions & 29 deletions src/explore/who-to-follow.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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) {
Expand All @@ -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);
Expand All @@ -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',
Expand All @@ -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)
Expand Down Expand Up @@ -97,7 +103,6 @@ export class WhoToFollowService {
candidate_multiplier,
};

//queries in parallel
const [
friends_of_friends,
interest_based,
Expand All @@ -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,
Expand Down Expand Up @@ -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);
Expand All @@ -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,
Expand Down Expand Up @@ -325,7 +312,6 @@ export class WhoToFollowService {
[];
const seen = new Set<string>();

// 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) {
Expand Down
5 changes: 3 additions & 2 deletions src/tweets/utils/file-upload.config.ts
Original file line number Diff line number Diff line change
@@ -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);
Expand Down