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
118 changes: 118 additions & 0 deletions backend/src/recommendations/recommendation-cache.service.ts
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>();
Comment on lines +11 to +15

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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-manager module configured with a Redis store. 1. Install dependencies: npm install @nestjs/cache-manager cache-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'; @Injectable export 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:

cat backend/src/recommendations/recommendation-cache.service.ts

Repository: OlufunbiIK/tip-tune

Length of output: 3604


In-memory Map cache won't survive multi-instance deployments and is unbounded.

Two operational concerns with the current implementation:

  1. Per-instance state. Behind a load balancer with N replicas, each instance has its own cache. Calls to 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.
  2. Unbounded growth. Entries are only evicted lazily when that user is read again after expiry (lines 39–40, 67–68). Users who request once and never come back leave entries in cache forever. With no max-size or periodic sweep, this becomes a slow memory leak proportional to unique-user count.

Recommended approach:

  • Back the cache with a shared store using Redis via @nestjs/cache-manager configured with cache-manager-redis-yet. This is the standard NestJS 10 (2025) pattern for multi-instance consistent caching and handles TTL, invalidation, and unbounded growth automatically.
  • If staying in-memory for single-instance scenarios, cap entries with an LRU eviction policy (e.g., lru-cache) and document the single-instance requirement.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/recommendations/recommendation-cache.service.ts` around lines 11
- 15, The in-memory Map in RecommendationCacheService is unsafe for
multi-instance deployments and unbounded; replace the private cache: Map with a
shared cache-manager (inject CACHE_MANAGER as cacheManager: Cache) backed by
Redis (cache-manager-redis-yet) and use cacheManager.get/set/del with TTLs so
recordFeedback, invalidateOnTipEvent, and invalidateOnFeedbackEvent call
cacheManager.del for the user keys (ensuring cluster-wide invalidation); if you
must keep in-memory for single-instance, swap the Map for an LRU implementation
(e.g., lru-cache) and enforce a max size and TTL plus a comment documenting
single-instance limitations.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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 recordFeedback allows duplicate feedback rows due to missing unique constraint.

The findOne → conditional createsave pattern is vulnerable to concurrent execution. When two requests simultaneously check for existing feedback on the same (userId, trackId) and both observe null, each will insert a new row. The entity has no unique constraint on (userId, trackId), so duplicates persist. This breaks non-determinism in downstream filtering (e.g., WHERE feedback = 'down' in collaborativeFilter and contentBasedFilter).

Fix by enforcing uniqueness at the database level and using upsert:

🛡️ Proposed fix using TypeORM upsert + unique constraint

In the entity:

-@Entity('recommendation_feedback')
+@Entity('recommendation_feedback')
+@Unique(['userId', 'trackId'])
 export class RecommendationFeedback {

In recommendation-cache.service.ts:

-    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
Verify each finding against the current code and only fix it if needed.

In `@backend/src/recommendations/recommendation-cache.service.ts` around lines 91
- 107, The recordFeedback flow (recordFeedback in
recommendation-cache.service.ts using feedbackRepo and RecommendationFeedback
entity) is racy because it does findOne → create → save without a DB uniqueness
constraint on (userId, trackId); add a unique index/constraint on the
RecommendationFeedback entity for (userId, trackId) and create a migration to
apply it, then replace the findOne/create/save sequence with an atomic upsert
(use feedbackRepo.upsert or repository save with ON CONFLICT/ON DUPLICATE KEY
semantics) to insert-or-update feedback atomically, keeping the
invalidateUserCache(userId) call after the upsert so cache invalidation still
occurs.


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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

🧩 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' -C2

Repository: OlufunbiIK/tip-tune

Length of output: 917


Event-driven cache invalidation for tip verification is not implemented — PR #496 requirement not met.

invalidateOnTipEvent is a dead method with no callers. While tip.verified events are emitted from tips.service.ts (line 204–207), RecommendationsModule does not import EventEmitterModule, and the method lacks an @OnEvent('tip.verified') decorator, so tip verification never invalidates the recommendations cache.

The feedback case is already handled directly: recordFeedback calls invalidateUserCache synchronously (line 104), so invalidateOnFeedbackEvent can be removed. However, tip verification invalidation must be added—either:

  • Decorate invalidateOnTipEvent with @OnEvent('tip.verified') and import EventEmitterModule into RecommendationsModule, or
  • Remove the event-based method and call cacheService.invalidateOnTipEvent(userId) directly from the tip verification path in tips.service.ts.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/recommendations/recommendation-cache.service.ts` around lines 109
- 117, The method invalidateOnTipEvent in recommendation-cache.service.ts is
never invoked because it lacks an `@OnEvent`('tip.verified') decorator and
RecommendationsModule does not import EventEmitterModule; remove the unused
invalidateOnFeedbackEvent (since recordFeedback already calls
invalidateUserCache) and implement tip verification invalidation by either (A)
adding `@OnEvent`('tip.verified') to invalidateOnTipEvent and importing
EventEmitterModule into RecommendationsModule so the event is handled, or (B)
removing invalidateOnTipEvent and calling
recommendationCacheService.invalidateUserCache(userId) directly from the tip
verification path in tips.service.ts where tip.verified is emitted; update
unit/integration tests accordingly.

}
5 changes: 3 additions & 2 deletions backend/src/recommendations/recommendations.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@ import { Module } from "@nestjs/common";
import { TypeOrmModule } from "@nestjs/typeorm";
import { RecommendationsController } from "./recommendations.controller";
import { RecommendationsService } from "./recommendations.service";
import { RecommendationCacheService } from "./recommendation-cache.service";
import { RecommendationFeedback } from "./entities/recommendation-feedback.entity";
import { AuthModule } from "../auth/auth.module";

@Module({
imports: [TypeOrmModule.forFeature([RecommendationFeedback]), AuthModule],
controllers: [RecommendationsController],
providers: [RecommendationsService],
exports: [RecommendationsService],
providers: [RecommendationsService, RecommendationCacheService],
exports: [RecommendationsService, RecommendationCacheService],
})
export class RecommendationsModule {}
43 changes: 26 additions & 17 deletions backend/src/recommendations/recommendations.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand All @@ -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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Cache can return fewer items than requested when limit varies between calls.

setTrackRecommendations(userId, recommendations) stores exactly boundedLimit items keyed only on userId, and getTrackRecommendations retrieves that array and slice(0, limit)s it. Sequence:

  1. Caller A requests limit=20 → cache stores 20 items.
  2. Caller B requests limit=50 (within the 1h TTL) → cache hit returns only 20 items — silently fewer than asked.

Downstream impact: getArtistRecommendations calls getTrackRecommendations(userId, 30) (Line 62), so any prior cached run with limit < 30 will degrade artist recommendations too.

Two ways to fix — pick one:

  • Include limit (or a small set of canonical buckets) in the cache key so different limits don't collide.
  • Always compute and cache the max bound (50), then slice on read; only treat entries with length >= limit as a hit.
♻️ Sketch of the second option (cache max, slice on read)

In recommendations.service.ts:

-    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 getTrackRecommendations to no longer slice (returning the raw cached array).

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/recommendations/recommendations.service.ts` around lines 34 - 54,
Cache entries must be keyed/structured so varying caller limits don't produce
undersized hits; implement the "cache max and slice on read" approach: when
reading in getTrackRecommendations(userId, limit) compute boundedLimit =
Math.max(1, Math.min(limit, 50)), call
cacheService.getTrackRecommendations(userId) but treat it as a raw cached array
and only treat it as a hit if cached && cached.length >= boundedLimit (return
cached.slice(0, boundedLimit)); when computing recommendations, always generate
and cache up to the maximum bucket (use 50) by calling
cacheService.setTrackRecommendations(userId, recommendations.slice(0, 50)) so
future reads can safely slice down; update cacheService.getTrackRecommendations
to return the raw stored array (no slicing) and ensure setTrackRecommendations
stores up to 50 items.


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>();

Expand All @@ -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> {
Expand Down
36 changes: 36 additions & 0 deletions backend/src/reports/dto/report-query.dto.ts
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

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

assignedToId has no actual validation.

@IsOptional() alone runs no constraint when the value is present, so ?assignedToId=... accepts anything — including arrays (?assignedToId=a&assignedToId=b), which Express parses into string[] and which TypeORM will silently turn into an IN filter. Constrain it to a UUID string to match the User.id shape.

🛡️ Proposed fix
   `@IsOptional`()
+  `@IsUUID`()
   assignedToId?: string;
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@IsOptional()
assignedToId?: string;
`@IsOptional`()
`@IsUUID`()
assignedToId?: string;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/reports/dto/report-query.dto.ts` around lines 18 - 19, The
assignedToId field currently only has `@IsOptional`() so any value (including
arrays) passes; update the ReportQueryDto's assignedToId declaration to enforce
a UUID string by adding the class-validator decorator `@IsUUID`() (keep
`@IsOptional`()), and ensure the TypeScript type remains string (assignedToId?:
string) so arrays are rejected by validation and only a proper User.id-shaped
UUID is accepted.


@IsOptional()
escalated?: boolean;
Comment on lines +21 to +22

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🔴 Critical

🧩 Analysis chain

🌐 Web query:

class-transformer Type Boolean does not coerce string "false" to false

💡 Result:

Yes, this is the expected and intentional behavior in class-transformer. When using @Type( => Boolean), the library applies JavaScript's native Boolean(value) coercion. Since Boolean("false") evaluates to true (any non-empty string is truthy), the string "false" is coerced to true. This matches JSON standards where booleans are native true/false, not strings. For query params or form data arriving as strings, use a custom @Transform decorator instead: import { Transform } from 'class-transformer'; @Transform(({ value }) => { if (typeof value === 'boolean') return value; const lower = value?.toString.toLowerCase; return lower === 'true' || lower === '1' ? true : (lower === 'false' || lower === '0' ? false : value); }) @IsBoolean @IsOptional public isActive: boolean; Ensure your ValidationPipe has transform: true: app.useGlobalPipes(new ValidationPipe({ transform: true })); This issue persists as of 2026 (last changelog ~2021, relevant PRs like #1776 still open). Official docs note primitive conversion but don't warn about this boolean gotcha specifically.

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 -100

Repository: OlufunbiIK/tip-tune

Length of output: 4584


escalated must be coerced from query string to boolean — filter is broken for ?escalated=false.

Query parameters always arrive as strings. Without @Transform, escalated will be the literal string "true" or "false" after validation, and reports.service.ts's where.escalated = query.escalated; passes that string directly to TypeORM. This breaks the filter: ?escalated=false will be truthy in JavaScript (non-empty string), causing incorrect query results.

Note: @Type(() => Boolean) does not fix this — Boolean("false") === true. Use an explicit @Transform decorator instead.

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

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
@IsOptional()
escalated?: boolean;
`@IsOptional`()
`@Transform`(({ value }) => {
if (typeof value === 'boolean') return value;
if (value === 'true') return true;
if (value === 'false') return false;
return value;
})
`@IsBoolean`()
escalated?: boolean;
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/reports/dto/report-query.dto.ts` around lines 21 - 22, The
escalated query param is arriving as a string so update the ReportQueryDto's
escalated property to coerce the string to a real boolean using
class-transformer's `@Transform` (e.g. `@Transform`(({ value }) => value ===
'true')), keep `@IsOptional`(), ensure the property type remains boolean
(escalated?: boolean) and import Transform from 'class-transformer' so
where.escalated receives a proper boolean instead of the string.


@IsOptional()
@Type(() => Number)
@IsInt()
@Min(1)
@Max(100)
limit?: number = 20;

@IsOptional()
@Type(() => Number)
@IsInt()
@Min(0)
offset?: number = 0;
}
Loading