diff --git a/src/timeline/services/foryou/for-you.service.ts b/src/timeline/services/foryou/for-you.service.ts index 707c065..7a43b33 100644 --- a/src/timeline/services/foryou/for-you.service.ts +++ b/src/timeline/services/foryou/for-you.service.ts @@ -30,11 +30,9 @@ export class ForyouService { cursor?: string, // Keep for API compatibility but not used limit: number = 20 ): Promise<{ - // data: ScoredCandidateDTO[]; data: TweetResponseDTO[]; pagination: { next_cursor: string | null; has_more: boolean }; }> { - // Get or create cursor for this user let timeline_cursor = await this.timeline_cursor_repository.findOne({ where: { user_id }, }); @@ -66,7 +64,6 @@ export class ForyouService { ); // Fallback: Fetch tweets directly from candidates service - // This handles the case where frontend calls immediately after assigning interests const candidates = await this.timeline_candidates_service.getCandidates( user_id, new Set(), // No exclusions for fresh start @@ -88,7 +85,7 @@ export class ForyouService { ); return { data: fallback_tweets, - pagination: { next_cursor: null, has_more: false }, + pagination: { next_cursor: 'next', has_more: true }, }; } diff --git a/src/timeline/services/timeline-candidates.service.spec.ts b/src/timeline/services/timeline-candidates.service.spec.ts index 952717f..9da66ef 100644 --- a/src/timeline/services/timeline-candidates.service.spec.ts +++ b/src/timeline/services/timeline-candidates.service.spec.ts @@ -6,13 +6,17 @@ import { Repository } from 'typeorm'; import { UserInterests } from 'src/user/entities/user-interests.entity'; import { TweetCategory } from 'src/tweets/entities/tweet-category.entity'; import { Tweet } from 'src/tweets/entities/tweet.entity'; +import { Category } from 'src/category/entities/category.entity'; +import { InitTimelineQueueJobService } from 'src/background-jobs/timeline/timeline.service'; describe('TimelineCandidatesService', () => { let service: TimelineCandidatesService; let user_interests_repository: jest.Mocked>; let tweet_category_repository: jest.Mocked>; let tweet_repository: jest.Mocked>; + let category_repository: jest.Mocked>; let config_service: jest.Mocked; + let init_timeline_queue_job_service: jest.Mocked; const mock_user_id = 'user-123'; const mock_user_interests = [ @@ -48,6 +52,8 @@ describe('TimelineCandidatesService', () => { provide: getRepositoryToken(UserInterests), useValue: { find: jest.fn(), + create: jest.fn(), + save: jest.fn(), }, }, { @@ -62,6 +68,22 @@ describe('TimelineCandidatesService', () => { createQueryBuilder: jest.fn(), }, }, + { + provide: getRepositoryToken(Category), + useValue: { + createQueryBuilder: jest.fn(() => ({ + orderBy: jest.fn().mockReturnThis(), + limit: jest.fn().mockReturnThis(), + getMany: jest.fn().mockResolvedValue([]), + })), + }, + }, + { + provide: InitTimelineQueueJobService, + useValue: { + queueInitTimelineQueue: jest.fn().mockResolvedValue(undefined), + }, + }, { provide: ConfigService, useValue: { @@ -78,6 +100,8 @@ describe('TimelineCandidatesService', () => { user_interests_repository = module.get(getRepositoryToken(UserInterests)); tweet_category_repository = module.get(getRepositoryToken(TweetCategory)); tweet_repository = module.get(getRepositoryToken(Tweet)); + category_repository = module.get(getRepositoryToken(Category)); + init_timeline_queue_job_service = module.get(InitTimelineQueueJobService); config_service = module.get(ConfigService); }); diff --git a/src/timeline/services/timeline-candidates.service.ts b/src/timeline/services/timeline-candidates.service.ts index 0c93f7f..4e9a6d3 100644 --- a/src/timeline/services/timeline-candidates.service.ts +++ b/src/timeline/services/timeline-candidates.service.ts @@ -5,6 +5,9 @@ import { Repository } from 'typeorm'; import { UserInterests } from 'src/user/entities/user-interests.entity'; import { TweetCategory } from 'src/tweets/entities/tweet-category.entity'; import { Tweet } from 'src/tweets/entities/tweet.entity'; +import { Category } from 'src/category/entities/category.entity'; +import { InitTimelineQueueJobService } from 'src/background-jobs/timeline/timeline.service'; +import { JOB_DELAYS } from 'src/background-jobs/constants/queue.constants'; export interface ICandidateTweet { tweet_id: string; @@ -25,40 +28,36 @@ export class TimelineCandidatesService { private readonly tweet_category_repository: Repository, @InjectRepository(Tweet) private readonly tweet_repository: Repository, - private readonly config_service: ConfigService + @InjectRepository(Category) + private readonly category_repository: Repository, + private readonly config_service: ConfigService, + private readonly init_timeline_queue_job_service: InitTimelineQueueJobService ) { this.tweet_freshness_days = this.config_service.get( 'TIMELINE_TWEET_FRESHNESS_DAYS', 7 ); - this.LIMIT_FACTOR = 500; // Factor to over-fetch for filtering + this.LIMIT_FACTOR = 500; } - /** - * Get candidate tweets based on user's interests - * @param user_id User ID - * @param excluded_tweet_ids Tweet IDs to exclude (already seen) - * @param limit Maximum number of candidates to return - * @returns Array of candidate tweets - */ async getCandidates( user_id: string, excluded_tweet_ids: Set, limit: number ): Promise { - // console.log( - // `[Candidates] Getting ${limit} candidates for user ${user_id}, excluding ${excluded_tweet_ids.size} tweets` - // ); const user_interests = await this.user_interests_repository.find({ where: { user_id }, order: { score: 'DESC' }, }); - // console.log(`[Candidates] Found ${user_interests.length} interests for user ${user_id}`); if (user_interests.length === 0) { - console.log(`[Candidates] No interests found, using random fallback`); - // Fallback: Get random fresh tweets if user has no interests + console.log(`[Candidates] No interests found, assigning 3 random interests`); + // No interests means that the user makes a refresh before inserting their interests + // Assign 3 random interests and trigger the init timeline queue job + await this.assignRandomInterests(user_id); + await this.init_timeline_queue_job_service.queueInitTimelineQueue({ user_id }); + // for now, return random tweets while the background job processes return this.getRandomFreshTweets(user_id, excluded_tweet_ids, limit); } @@ -299,4 +298,34 @@ export class TimelineCandidatesService { return candidates; } + + private async assignRandomInterests(user_id: string): Promise { + try { + const random_categories = await this.category_repository + .createQueryBuilder('category') + .orderBy('RANDOM()') + .limit(3) + .getMany(); + + if (random_categories.length === 0) { + console.error(`[Candidates] No categories available to assign`); + return; + } + + const user_interests = random_categories.map((category) => + this.user_interests_repository.create({ + user_id, + category_id: String(category.id), + score: 100, + }) + ); + + await this.user_interests_repository.save(user_interests); + } catch (error) { + console.error( + `[Candidates] Error assigning random interests to user ${user_id}:`, + error + ); + } + } } diff --git a/src/timeline/timeline.module.ts b/src/timeline/timeline.module.ts index 7d895dd..65ce742 100644 --- a/src/timeline/timeline.module.ts +++ b/src/timeline/timeline.module.ts @@ -15,6 +15,7 @@ import { TimelineRedisService } from './services/timeline-redis.service'; import { TimelineCandidatesService } from './services/timeline-candidates.service'; import { BackgroundJobsModule } from 'src/background-jobs/background-jobs.module'; import { RedisModuleConfig } from 'src/redis/redis.module'; +import { Category } from 'src/category/entities'; @Module({ imports: [ @@ -26,6 +27,7 @@ import { RedisModuleConfig } from 'src/redis/redis.module'; TweetCategory, UserInterests, UserTimelineCursor, + Category, ]), forwardRef(() => BackgroundJobsModule), RedisModuleConfig,