-
Notifications
You must be signed in to change notification settings - Fork 84
feat: scheduled releases, recommendations cache, reports audit trail, and pagination #532
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
010d45a
c8132c3
9e1d95e
0bb3f2d
bf89e59
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,118 @@ | ||
| import { Injectable, Logger } from "@nestjs/common"; | ||
| import { InjectRepository } from "@nestjs/typeorm"; | ||
| import { Repository } from "typeorm"; | ||
| import { RecommendationFeedback } from "./entities/recommendation-feedback.entity"; | ||
|
|
||
| interface CacheEntry { | ||
| data: any[]; | ||
| expiresAt: number; | ||
| } | ||
|
|
||
| @Injectable() | ||
| export class RecommendationCacheService { | ||
| private readonly logger = new Logger(RecommendationCacheService.name); | ||
| private readonly cacheTtlMs = 3600000; | ||
| private cache = new Map<string, CacheEntry>(); | ||
|
|
||
| constructor( | ||
| @InjectRepository(RecommendationFeedback) | ||
| private readonly feedbackRepo: Repository<RecommendationFeedback>, | ||
| ) {} | ||
|
|
||
| getCacheKey(userId: string, type: 'track' | 'artist'): string { | ||
| return `recommendations:${type}:${userId}`; | ||
| } | ||
|
|
||
| async getTrackRecommendations( | ||
| userId: string, | ||
| limit: number = 20, | ||
| ): Promise<any[] | null> { | ||
| const cacheKey = this.getCacheKey(userId, 'track'); | ||
| const entry = this.cache.get(cacheKey); | ||
|
|
||
| if (entry && entry.expiresAt > Date.now()) { | ||
| this.logger.debug(`Track recommendations cache hit for user ${userId}`); | ||
| return entry.data.slice(0, limit); | ||
| } | ||
|
|
||
| if (entry && entry.expiresAt <= Date.now()) { | ||
| this.cache.delete(cacheKey); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| async setTrackRecommendations( | ||
| userId: string, | ||
| recommendations: any[], | ||
| ): Promise<void> { | ||
| const cacheKey = this.getCacheKey(userId, 'track'); | ||
| this.cache.set(cacheKey, { | ||
| data: recommendations, | ||
| expiresAt: Date.now() + this.cacheTtlMs, | ||
| }); | ||
| this.logger.debug(`Track recommendations cached for user ${userId}`); | ||
| } | ||
|
|
||
| async getArtistRecommendations(userId: string): Promise<any[] | null> { | ||
| const cacheKey = this.getCacheKey(userId, 'artist'); | ||
| const entry = this.cache.get(cacheKey); | ||
|
|
||
| if (entry && entry.expiresAt > Date.now()) { | ||
| this.logger.debug(`Artist recommendations cache hit for user ${userId}`); | ||
| return entry.data; | ||
| } | ||
|
|
||
| if (entry && entry.expiresAt <= Date.now()) { | ||
| this.cache.delete(cacheKey); | ||
| } | ||
|
|
||
| return null; | ||
| } | ||
|
|
||
| async setArtistRecommendations( | ||
| userId: string, | ||
| recommendations: any[], | ||
| ): Promise<void> { | ||
| const cacheKey = this.getCacheKey(userId, 'artist'); | ||
| this.cache.set(cacheKey, { | ||
| data: recommendations, | ||
| expiresAt: Date.now() + this.cacheTtlMs, | ||
| }); | ||
| this.logger.debug(`Artist recommendations cached for user ${userId}`); | ||
| } | ||
|
|
||
| async invalidateUserCache(userId: string): Promise<void> { | ||
| this.cache.delete(this.getCacheKey(userId, 'track')); | ||
| this.cache.delete(this.getCacheKey(userId, 'artist')); | ||
| this.logger.debug(`Invalidated recommendation cache for user ${userId}`); | ||
| } | ||
|
|
||
| async recordFeedback( | ||
| userId: string, | ||
| trackId: string, | ||
| feedback: 'up' | 'down', | ||
| ): Promise<RecommendationFeedback> { | ||
| const existing = await this.feedbackRepo.findOne({ | ||
| where: { userId, trackId }, | ||
| }); | ||
|
|
||
| const entry = existing || this.feedbackRepo.create({ userId, trackId }); | ||
| entry.feedback = feedback; | ||
| const saved = await this.feedbackRepo.save(entry); | ||
|
|
||
| await this.invalidateUserCache(userId); | ||
|
|
||
| return saved; | ||
| } | ||
|
Comment on lines
+91
to
+107
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# Confirm there is no existing unique constraint / unique index on (userId, trackId)
# in the entity or migrations that would already prevent duplicates.
fd -t f 'recommendation-feedback*' | xargs -I{} sh -c 'echo "===== {} ====="; cat {}'
echo "----- migrations referencing recommendation_feedback -----"
rg -nP --type=ts -C2 'recommendation_feedback|RecommendationFeedback' -g '!**/node_modules/**'Repository: OlufunbiIK/tip-tune Length of output: 7280 Race condition in The Fix by enforcing uniqueness at the database level and using upsert: 🛡️ Proposed fix using TypeORM upsert + unique constraintIn the entity: -@Entity('recommendation_feedback')
+@Entity('recommendation_feedback')
+@Unique(['userId', 'trackId'])
export class RecommendationFeedback {In - const existing = await this.feedbackRepo.findOne({
- where: { userId, trackId },
- });
-
- const entry = existing || this.feedbackRepo.create({ userId, trackId });
- entry.feedback = feedback;
- const saved = await this.feedbackRepo.save(entry);
-
- await this.invalidateUserCache(userId);
-
- return saved;
+ await this.feedbackRepo.upsert(
+ { userId, trackId, feedback },
+ { conflictPaths: ['userId', 'trackId'] },
+ );
+ await this.invalidateUserCache(userId);
+ const saved = await this.feedbackRepo.findOneOrFail({
+ where: { userId, trackId },
+ });
+ return saved;A migration adding the unique index is also needed. 🤖 Prompt for AI Agents |
||
|
|
||
| async invalidateOnTipEvent(userId: string): Promise<void> { | ||
| await this.invalidateUserCache(userId); | ||
| this.logger.debug(`Invalidated cache due to tip event for user ${userId}`); | ||
| } | ||
|
|
||
| async invalidateOnFeedbackEvent(userId: string): Promise<void> { | ||
| await this.invalidateUserCache(userId); | ||
| this.logger.debug(`Invalidated cache due to feedback event for user ${userId}`); | ||
| } | ||
|
Comment on lines
+109
to
+117
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🏁 Script executed: #!/bin/bash
# 1) Are there any callers of these invalidation hooks anywhere?
rg -nP --type=ts -C2 '\binvalidateOn(Tip|Feedback)Event\s*\('
# 2) Is EventEmitterModule wired in app/recommendations modules?
rg -nP --type=ts -C2 'EventEmitterModule|@OnEvent'
# 3) Does tip verification emit any event we could subscribe to?
rg -nP --type=ts -C3 '\beventEmitter\.emit\b|\bEventEmitter2\b' -g '!**/node_modules/**'Repository: OlufunbiIK/tip-tune Length of output: 43268 🏁 Script executed: # Check if RecommendationsModule imports EventEmitterModule
cat -n backend/src/recommendations/recommendations.module.ts
# Search for feedback-related event handling
rg -nP --type=ts 'recordFeedback|feedback\..*event|recommendation\.feedback' -C2
# Confirm no callers of the invalidation methods
rg -nP --type=ts 'invalidateOnTipEvent|invalidateOnFeedbackEvent' | grep -v 'async invalidateOn'Repository: OlufunbiIK/tip-tune Length of output: 3073 🏁 Script executed: # Read the recordFeedback method to see if it calls invalidateOnFeedbackEvent
sed -n '91,117p' backend/src/recommendations/recommendation-cache.service.ts
# Search for any feedback event emission
rg -nP --type=ts 'emit.*feedback|eventEmitter.*feedback' -C2Repository: OlufunbiIK/tip-tune Length of output: 917 Event-driven cache invalidation for tip verification is not implemented — PR
The feedback case is already handled directly:
🤖 Prompt for AI Agents |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -2,6 +2,7 @@ import { Injectable, Logger } from "@nestjs/common"; | |
| import { InjectRepository } from "@nestjs/typeorm"; | ||
| import { Repository, DataSource } from "typeorm"; | ||
| import { RecommendationFeedback } from "./entities/recommendation-feedback.entity"; | ||
| import { RecommendationCacheService } from "./recommendation-cache.service"; | ||
| import { TipStatus } from "../tips/entities/tip.entity"; | ||
|
|
||
| type RecommendationTrackRow = { | ||
|
|
@@ -23,26 +24,41 @@ export class RecommendationsService { | |
| @InjectRepository(RecommendationFeedback) | ||
| private readonly feedbackRepo: Repository<RecommendationFeedback>, | ||
| private readonly dataSource: DataSource, | ||
| private readonly cacheService: RecommendationCacheService, | ||
| ) {} | ||
|
|
||
| async getTrackRecommendations( | ||
| userId: string, | ||
| limit: number = 20, | ||
| ): Promise<any[]> { | ||
| const boundedLimit = Math.max(1, Math.min(limit, 50)); | ||
|
|
||
| const cached = await this.cacheService.getTrackRecommendations(userId, boundedLimit); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
|
|
||
| const tipCount = await this.getUserTipCount(userId); | ||
|
|
||
| let recommendations: any[]; | ||
| if (tipCount < 3) { | ||
| return this.getPopularTracks(boundedLimit); | ||
| recommendations = await this.getPopularTracks(boundedLimit); | ||
| } else { | ||
| const collaborative = await this.collaborativeFilter(userId, boundedLimit); | ||
| const contentBased = await this.contentBasedFilter(userId, boundedLimit); | ||
| recommendations = this.mergeRecommendations(collaborative, contentBased, boundedLimit); | ||
| } | ||
|
|
||
| const collaborative = await this.collaborativeFilter(userId, boundedLimit); | ||
| const contentBased = await this.contentBasedFilter(userId, boundedLimit); | ||
|
|
||
| return this.mergeRecommendations(collaborative, contentBased, boundedLimit); | ||
| await this.cacheService.setTrackRecommendations(userId, recommendations); | ||
| return recommendations; | ||
| } | ||
|
Comment on lines
34
to
54
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Cache can return fewer items than requested when
Downstream impact: Two ways to fix — pick one:
♻️ Sketch of the second option (cache max, slice on read)In - const cached = await this.cacheService.getTrackRecommendations(userId, boundedLimit);
- if (cached) {
- return cached;
+ const cached = await this.cacheService.getTrackRecommendations(userId, boundedLimit);
+ if (cached && cached.length >= boundedLimit) {
+ return cached.slice(0, boundedLimit);
}
@@
- let recommendations: any[];
+ const MAX_LIMIT = 50;
+ let recommendations: any[];
if (tipCount < 3) {
- recommendations = await this.getPopularTracks(boundedLimit);
+ recommendations = await this.getPopularTracks(MAX_LIMIT);
} else {
- const collaborative = await this.collaborativeFilter(userId, boundedLimit);
- const contentBased = await this.contentBasedFilter(userId, boundedLimit);
- recommendations = this.mergeRecommendations(collaborative, contentBased, boundedLimit);
+ const collaborative = await this.collaborativeFilter(userId, MAX_LIMIT);
+ const contentBased = await this.contentBasedFilter(userId, MAX_LIMIT);
+ recommendations = this.mergeRecommendations(collaborative, contentBased, MAX_LIMIT);
}
await this.cacheService.setTrackRecommendations(userId, recommendations);
- return recommendations;
+ return recommendations.slice(0, boundedLimit);And update the cache 🤖 Prompt for AI Agents |
||
|
|
||
| async getArtistRecommendations(userId: string): Promise<any[]> { | ||
| const cached = await this.cacheService.getArtistRecommendations(userId); | ||
| if (cached) { | ||
| return cached; | ||
| } | ||
|
|
||
| const trackRecommendations = await this.getTrackRecommendations(userId, 30); | ||
| const artists = new Map<string, any>(); | ||
|
|
||
|
|
@@ -67,27 +83,20 @@ export class RecommendationsService { | |
| }); | ||
| } | ||
|
|
||
| return [...artists.values()] | ||
| const recommendations = [...artists.values()] | ||
| .sort((a, b) => b.score - a.score || b.trackCount - a.trackCount) | ||
| .slice(0, 10); | ||
|
|
||
| await this.cacheService.setArtistRecommendations(userId, recommendations); | ||
| return recommendations; | ||
| } | ||
|
|
||
| async recordFeedback( | ||
| userId: string, | ||
| trackId: string, | ||
| feedback: "up" | "down", | ||
| ): Promise<RecommendationFeedback> { | ||
| const existing = await this.feedbackRepo.findOne({ | ||
| where: { userId, trackId }, | ||
| }); | ||
|
|
||
| if (existing) { | ||
| existing.feedback = feedback; | ||
| return this.feedbackRepo.save(existing); | ||
| } | ||
|
|
||
| const entry = this.feedbackRepo.create({ userId, trackId, feedback }); | ||
| return this.feedbackRepo.save(entry); | ||
| return this.cacheService.recordFeedback(userId, trackId, feedback); | ||
| } | ||
|
|
||
| private async getUserTipCount(userId: string): Promise<number> { | ||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,36 @@ | ||||||||||||||||||||||||
| import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator'; | ||||||||||||||||||||||||
| import { Type } from 'class-transformer'; | ||||||||||||||||||||||||
| import { ReportStatus, ReportEntityType, ReportPriority } from '../entities/report.entity'; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| export class ReportQueryDto { | ||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| @IsEnum(ReportStatus) | ||||||||||||||||||||||||
| status?: ReportStatus; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| @IsEnum(ReportEntityType) | ||||||||||||||||||||||||
| entityType?: ReportEntityType; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| @IsEnum(ReportPriority) | ||||||||||||||||||||||||
| priority?: ReportPriority; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| assignedToId?: string; | ||||||||||||||||||||||||
|
Comment on lines
+18
to
+19
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
🛡️ Proposed fix `@IsOptional`()
+ `@IsUUID`()
assignedToId?: string;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| escalated?: boolean; | ||||||||||||||||||||||||
|
Comment on lines
+21
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes, this is the expected and intentional behavior in class-transformer. When using Citations:
🏁 Script executed: fd -t f "report-query.dto.ts" -x cat -n {}Repository: OlufunbiIK/tip-tune Length of output: 1028 🏁 Script executed: fd -t f "reports.service.ts" | head -1 | xargs cat -n | head -100Repository: OlufunbiIK/tip-tune Length of output: 4584
Query parameters always arrive as strings. Without Note: Proposed fix-import { IsEnum, IsOptional, IsInt, Min, Max } from 'class-validator';
-import { Type } from 'class-transformer';
+import { IsEnum, IsOptional, IsInt, Min, Max, IsBoolean } from 'class-validator';
+import { Type, Transform } from 'class-transformer'; `@IsOptional`()
+ `@Transform`(({ value }) => {
+ if (typeof value === 'boolean') return value;
+ if (value === 'true') return true;
+ if (value === 'false') return false;
+ return value;
+ })
+ `@IsBoolean`()
escalated?: boolean;📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| @Type(() => Number) | ||||||||||||||||||||||||
| @IsInt() | ||||||||||||||||||||||||
| @Min(1) | ||||||||||||||||||||||||
| @Max(100) | ||||||||||||||||||||||||
| limit?: number = 20; | ||||||||||||||||||||||||
|
|
||||||||||||||||||||||||
| @IsOptional() | ||||||||||||||||||||||||
| @Type(() => Number) | ||||||||||||||||||||||||
| @IsInt() | ||||||||||||||||||||||||
| @Min(0) | ||||||||||||||||||||||||
| offset?: number = 0; | ||||||||||||||||||||||||
| } | ||||||||||||||||||||||||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🧩 Analysis chain
🌐 Web query:
What is the recommended NestJS 10 approach (2025) for a per-user TTL recommendation cache that needs to be consistent across multiple instances behind a load balancer?💡 Result:
The recommended NestJS 10 approach (as of 2025) for a per-user TTL recommendation cache consistent across multiple instances behind a load balancer is to use the official
@nestjs/cache-managermodule configured with a Redis store. 1. Install dependencies: npm install@nestjs/cache-managercache-manager cache-manager-redis-yet 2. Register CacheModule globally with Redis in AppModule (or a dedicated CacheModule): import { Module } from '@nestjs/common'; import { CacheModule } from '@nestjs/cache-manager'; import { redisStore } from 'cache-manager-redis-yet'; import { ConfigService } from '@nestjs/config';@Module({ imports: [ CacheModule.registerAsync({ isGlobal: true, useFactory: async (configService: ConfigService) => { const store = await redisStore({ socket: { host: configService.get('REDIS_HOST', 'localhost'), port: configService.get('REDIS_PORT', 6379), }, ttl: 0, // No default TTL; set per-operation }); return { store }; }, inject: [ConfigService], }), ], }) export class AppModule {} This uses Redis as the shared store, ensuring consistency across instances. In-memory won't work for multi-instance setups. 3. Inject CACHE_MANAGER and use per-user keys with dynamic TTL: import { Injectable, Inject } from '@nestjs/common'; import { CACHE_MANAGER } from '@nestjs/cache-manager'; import { Cache } from 'cache-manager';@Injectableexport class RecommendationService { constructor(@Inject(CACHE_MANAGER) private cacheManager: Cache) {} async getRecommendations(userId: string, ttlMs: number) { const key = recommendations:user:${userId}; let recs = await this.cacheManager.get(key); if (!recs) { recs = await this.computeRecommendations(userId); // Expensive computation // Set with dynamic TTL (ms); use { ttl: ttlMs / 1000 } for seconds if needed based on store version await this.cacheManager.set(key, recs, ttlMs); } return recs; } } Key prefixing with user:${userId} ensures per-user isolation. TTL is set dynamically per set call. For TypeScript issues with { ttl }, cast as any or use number directly (check cache-manager version compatibility). For advanced features (L1/L2 cache, stampede protection, tags), consider NestJS RedisX (@nestjs-redisx/cache), but official cache-manager + Redis is the standard baseline per docs.Citations:
🏁 Script executed:
Repository: OlufunbiIK/tip-tune
Length of output: 3604
In-memory
Mapcache won't survive multi-instance deployments and is unbounded.Two operational concerns with the current implementation:
recordFeedback(line 92) only invalidate on the instance that handled the request, so other replicas continue serving stale recommendations until TTL expiry. The same applies to event-based invalidation (invalidateOnTipEvent,invalidateOnFeedbackEvent) unless events are broadcast cluster-wide.cacheforever. With no max-size or periodic sweep, this becomes a slow memory leak proportional to unique-user count.Recommended approach:
@nestjs/cache-managerconfigured withcache-manager-redis-yet. This is the standard NestJS 10 (2025) pattern for multi-instance consistent caching and handles TTL, invalidation, and unbounded growth automatically.lru-cache) and document the single-instance requirement.🤖 Prompt for AI Agents