Skip to content

feat: scheduled releases, recommendations cache, reports audit trail, and pagination - #532

Merged
OlufunbiIK merged 5 commits into
Tip-tune-org:mainfrom
buinntalen:backend/scheduled-release-retry-audit-cache-496-497-498-499
Apr 25, 2026
Merged

feat: scheduled releases, recommendations cache, reports audit trail, and pagination#532
OlufunbiIK merged 5 commits into
Tip-tune-org:mainfrom
buinntalen:backend/scheduled-release-retry-audit-cache-496-497-498-499

Conversation

@buinntalen

@buinntalen buinntalen commented Apr 25, 2026

Copy link
Copy Markdown
Contributor

Summary

Implements four high-complexity backend features in the Stellar Wave program:

Also fixes pre-existing TypeScript compilation errors blocking the build.

Changes

  • Add ScheduledReleaseRetryPolicy with exponential backoff configuration
  • Update scheduler cron to check nextRetryAt before retry eligibility
  • Add RecommendationCacheService with in-memory snapshot caching (1h TTL)
  • Integrate cache invalidation on feedback and tip events
  • Create ReportEnforcementService for auditable user bans and content removals
  • Define ReportQueryDto with class-validator typed filters and pagination
  • Return paginated responses with total count and metadata

Testing

  • Backend linting: ✅ (0 errors, 158 warnings)
  • Backend build: ✅ (NestJS compilation successful)
  • All implementation files compile without errors
  • Existing test infrastructure preserved

Fixes

  • Fixed missing imports in stellar.service (Injectable, Logger)
  • Fixed property name in tips.service (fromUser vs fromUserId)
  • Fixed deprecated enum usage in licensing services
  • Fixed unsupported QueryBuilder methods in track services

Closes #499
Closes #498
Closes #497
Closes #496

Summary by CodeRabbit

  • New Features

    • Added report filtering and pagination for improved management.
    • Added ability to enforce and reverse report actions on users and content.
    • Implemented automatic retry mechanism for scheduled releases with exponential backoff.
    • Added recommendation caching to improve performance.
  • Bug Fixes

    • Fixed soft-delete handling for track file cleanup.
    • Corrected referral reward logic for tip tracking.
    • Fixed licensing status labels in notifications.

…At enforcement

Implement exponential backoff retry strategy as a dedicated policy object.
Update the cron query to respect nextRetryAt windows, preventing retries
before their scheduled backoff period elapses. This ensures failed releases
honor the configured retry delay before reattemption.

Refs Tip-tune-org#499
Add RecommendationCacheService to cache user recommendation snapshots with
1-hour TTL. Invalidate cache on new tips or feedback to prevent stale
recommendations. Cache hits reduce query overhead; cold-start users fall back
to live calculation.

Refs Tip-tune-org#496
Separate enforcement actions from report resolution via ReportEnforcementService.
Create audit logs for all user bans and content removals, tracking previous
and new state. Support enforcement reversal through defined service methods.
Makes all actions auditable and traceable back to originating reports.

Refs Tip-tune-org#498
Define ReportQueryDto with class-validator validation for typed filter fields,
pagination metadata, and strict query semantics. Controller validates and
transforms raw queries. Service returns paginated results with total count.
Reject invalid query parameters explicitly.

Refs Tip-tune-org#497
Fix missing imports in stellar.service (Injectable, Logger), incorrect
property name in tips.service (fromUser not fromUserId), deprecated enum
usage in licensing services (LicenseRequestStatus to LicensingLifecycle),
and unsupported withDeleted() method in track services. These pre-existing
errors were blocking CI and preventing the build from succeeding.
@vercel

vercel Bot commented Apr 25, 2026

Copy link
Copy Markdown

@buinntalen is attempting to deploy a commit to the olufunbiik's projects Team on Vercel.

A member of the Team first needs to authorize it.

@coderabbitai

coderabbitai Bot commented Apr 25, 2026

Copy link
Copy Markdown
📝 Walkthrough

Walkthrough

This PR introduces three new services for caching recommendations, enforcing report-driven actions with audit trails, and implementing configurable retry policies for scheduled releases. It adds pagination and validation to report queries, updates several existing service implementations to integrate the new services, and makes minor fixes to licensing, tips, and track handling logic.

Changes

Cohort / File(s) Summary
Recommendation Caching
backend/src/recommendations/recommendation-cache.service.ts, backend/src/recommendations/recommendations.module.ts, backend/src/recommendations/recommendations.service.ts
New RecommendationCacheService manages in-memory per-user/per-type recommendation cache with 1-hour TTL; includes cache key generation, getters/setters for track and artist recommendations, cache invalidation on feedback/tip events, and recordFeedback persistence. RecommendationsService updated to integrate cache for hit/miss and store computed recommendations before returning; feedback recording delegated to cache service.
Report Enforcement & Audit
backend/src/reports/report-enforcement.service.ts, backend/src/reports/reports.service.ts, backend/src/reports/reports.module.ts
New ReportEnforcementService applies and reverses enforcement actions (user bans, track hiding) with full audit trail via AdminAuditLog; stores pre/post state and links actions to originating reports. ReportsService.updateStatus now delegates enforcement to the new service instead of direct repository updates; adds audit log repository dependency.
Report Pagination & Query Validation
backend/src/reports/dto/report-query.dto.ts, backend/src/reports/reports.controller.ts
New ReportQueryDto enforces typed filters (status, entityType, priority, assignedToId, escalated) with class-validator constraints and pagination controls (limit 1–100, offset ≥ 0). Controller updated to validate incoming query via plainToInstance and validate before passing to service.
Report Query Response
backend/src/reports/reports.service.ts
findAll signature changed to return paginated object with { data: Report[]; total: number; limit: number; offset: number } instead of bare array; filtering and pagination via query builder findAndCount.
Scheduled Release Retry Policy
backend/src/scheduled-releases/scheduled-release-retry.policy.ts, backend/src/scheduled-releases/scheduled-releases.service.ts
New ScheduledReleaseRetryPolicy interface and ScheduledReleaseRetryPolicy class implement configurable retry delay (fixed or exponential backoff) and shouldRetry logic that respects nextRetryAt window. ScheduledReleasesService updated to query only releases with null or due nextRetryAt, delegate retry decisions to policy, and compute next retry time via policy instead of fixed delay.
Licensing Lifecycle Enum Usage
backend/src/track-listening-right-management/licensing.service.ts, backend/src/track-listening-right-management/licensing-mail.service.ts
References to LicenseRequestStatus.PENDING and LicenseRequestStatus.APPROVED replaced with LicensingLifecycle.PENDING and LicensingLifecycle.APPROVED enum values.
Track Soft-Delete Handling
backend/src/tracks/track-file-reaper.service.ts, backend/src/tracks/tracks.service.ts
Queries that clear Track.filename changed from .withDeleted() to explicit "deletedAt" IS NOT NULL condition, ensuring only soft-deleted records are affected.
Tips & Stellar Minor Updates
backend/src/tips/tips.service.ts, backend/src/stellar/stellar.service.ts
TipsService referral logic changed to count tips by fromUser field instead of fromUserId; StellarService now explicitly imports Injectable and Logger from @nestjs/common.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 A cache we built with whiskers keen,
Reports enforced, trails squeaky clean,
Retries backoff with flair and grace—
No more stale hops, just fresh-picked pace! 🥕✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 inconclusive)

Check name Status Explanation Resolution
Out of Scope Changes check ❓ Inconclusive Several changes appear tangential to the four core features: tips.service.ts field rename (fromUser/fromUserId), licensing-mail/licensing.service enum migration (LicenseRequestStatus→LicensingLifecycle), track-file-reaper/tracks.service deletedAt logic, and stellar.service imports. Clarify whether licensing, tips field, track file cleanup, and stellar logging changes are necessary prerequisites or separate issues that should be scoped separately.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The pull request title concisely summarizes the four main features being implemented: scheduled releases, recommendations cache, reports audit trail, and pagination.
Linked Issues check ✅ Passed All four linked issues (#499, #498, #497, #496) have corresponding implementations: ScheduledReleaseRetryPolicy with nextRetryAt enforcement, ReportEnforcementService with audit logging, ReportQueryDto with pagination, and RecommendationCacheService with TTL-based caching.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 13

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
backend/src/scheduled-releases/scheduled-releases.service.ts (1)

251-304: ⚠️ Potential issue | 🟡 Minor

retryCount is not persisted on the terminal failure.

When the final failure occurs, handleReleaseFailure computes retryCount = release.retryCount + 1 but jumps straight to markAsPermanentlyFailed(release) without persisting that increment. As a result:

  • The scheduled_releases.retryCount column ends one short of actual attempts.
  • The DLQ entry at line 294 records the stale release.retryCount, while the lastError string at line 293/284 reports retryPolicy.maxRetries attempts. These two fields will disagree on permanent-failure rows that took the full retry budget.

Pass the up-to-date count through, or persist it before the terminal transition:

♻️ Proposed fix
   private async handleReleaseFailure(
     release: ScheduledRelease,
     error: Error,
   ): Promise<void> {
     const retryCount = release.retryCount + 1;
     const nextRetryTime = this.retryPolicy.getNextRetryTime(retryCount);

     if (retryCount < this.retryPolicy.maxRetries) {
       // Schedule for retry
       await this.scheduledReleaseRepository.update(release.id, {
         retryCount,
         lastError: error.message,
         lastAttemptAt: new Date(),
         status: ReleaseStatus.PENDING,
         nextRetryAt: nextRetryTime,
       });

       this.logger.log(
         `Release ${release.id} scheduled for retry #${retryCount} at ${nextRetryTime.toISOString()}`,
       );
     } else {
-      await this.markAsPermanentlyFailed(release);
+      release.retryCount = retryCount;
+      release.lastError = error.message;
+      release.lastAttemptAt = new Date();
+      await this.markAsPermanentlyFailed(release);
     }
   }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/scheduled-releases/scheduled-releases.service.ts` around lines
251 - 304, The terminal-failure path in handleReleaseFailure computes retryCount
= release.retryCount + 1 but never persists or passes that increment into
markAsPermanentlyFailed, so DB and DLQ keep the stale count; fix by either
updating the record with retryCount before calling markAsPermanentlyFailed or
changing markAsPermanentlyFailed to accept a retryCount param and use it when
calling scheduledReleaseRepository.update and dlqService.createEntry (ensure
lastError/failure message still references retryPolicy.maxRetries but retryCount
field uses the incremented value). Target symbols: handleReleaseFailure,
markAsPermanentlyFailed, scheduledReleaseRepository.update,
dlqService.createEntry, retryCount, retryPolicy.maxRetries.
backend/src/reports/reports.service.ts (1)

100-114: ⚠️ Potential issue | 🟠 Major

Enforcement and report-save are not atomic.

applyEnforcement performs persistent writes (user/track update + audit log) and then the report itself is saved on line 114. If reportsRepository.save(report) fails (validation, conn drop, optimistic-lock), the user is already banned / track already hidden and an audit row exists, but the originating Report keeps its old status/action — leaving operators an inconsistent state to clean up by hand.

Wrap the enforcement + report save in a single TypeORM transaction (DataSource.transaction or a QueryRunner), or move enforcement to fire after a successful report save and accept that the audit log is what proves the side-effect.

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

In `@backend/src/reports/reports.service.ts` around lines 100 - 114, The
enforcement call (enforcementService.applyEnforcement) and the subsequent
reportsRepository.save(report) must be executed atomically to avoid inconsistent
state; wrap the resolvedAt assignment, reportsRepository.save(report) and
enforcementService.applyEnforcement(...) inside a single TypeORM transaction
(use this.dataSource.transaction or a QueryRunner) so both the report update and
the enforcement writes/audit commit or roll back together. Update
enforcementService.applyEnforcement to accept an optional
EntityManager/QueryRunner (or use repositories from the transaction manager) and
call it with that manager inside the transaction block; alternatively obtain
repositories from the transaction manager to save the report (instead of
this.reportsRepository) so all DB writes use the same transactional context.
Ensure you still set report.resolvedAt when updateDto.status is
RESOLVED/DISMISSED and preserve checks for updateDto.action !==
ReportAction.NONE inside the transaction.
🧹 Nitpick comments (8)
backend/src/track-listening-right-management/licensing-mail.service.ts (1)

40-56: Consider matching REJECTED explicitly instead of treating it as the default branch.

The ternaries on lines 42 and 48 now collapse every non-APPROVED status into a "rejected" label/subject. With the lifecycle enum, request.status can also be WITHDRAWN, EXPIRED, REOPENED, or PENDING. Today the "response" mail is only enqueued from respondToRequest after a transition to APPROVED/REJECTED, but the mail handler reloads the request by id when it dequeues, so a request that was subsequently withdrawn/expired/reopened before the worker runs will be emailed as "Rejected ❌". Matching LicensingLifecycle.REJECTED explicitly (and skipping/erroring on other states) would make the notification accurate and forward-compatible with the broader lifecycle.

♻️ Suggested change
   async notifyRequesterOfResponse(request: LicenseRequest): Promise<void> {
-    const statusLabel =
-      request.status === LicensingLifecycle.APPROVED
-        ? "approved ✅"
-        : "rejected ❌";
+    let statusLabel: string;
+    let subjectStatus: string;
+    if (request.status === LicensingLifecycle.APPROVED) {
+      statusLabel = "approved ✅";
+      subjectStatus = "Approved";
+    } else if (request.status === LicensingLifecycle.REJECTED) {
+      statusLabel = "rejected ❌";
+      subjectStatus = "Rejected";
+    } else {
+      this.logger.warn(
+        `Skipping response email for request ${request.id} in non-response state '${request.status}'`,
+      );
+      return;
+    }

     await this.sendMail({
       to: `user+${request.requesterId}@platform.local`,
-      subject: `Your License Request Has Been ${request.status === LicensingLifecycle.APPROVED ? "Approved" : "Rejected"}`,
+      subject: `Your License Request Has Been ${subjectStatus}`,
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/track-listening-right-management/licensing-mail.service.ts`
around lines 40 - 56, The current notifyRequesterOfResponse function treats any
non-APPROVED status as "rejected", which mislabels states like
WITHDRAWN/EXPIRED/PENDING; update notifyRequesterOfResponse to check
request.status explicitly against LicensingLifecycle.APPROVED and
LicensingLifecycle.REJECTED (use those two branches to set statusLabel and
subject) and handle any other states by either skipping sending the email or
throwing/logging an error so sendMail is not invoked for unexpected lifecycle
values; make the change in the notifyRequesterOfResponse method and ensure any
returned/early-exit behavior is clear to the caller.
backend/src/track-listening-right-management/licensing.service.ts (1)

11-29: LGTM — lifecycle enum migration is consistent with the entity.

Switching the duplicate-pending lookup (line 107) and the new request's initial status (line 129) to LicensingLifecycle.PENDING matches LicenseRequest.status being typed/columned as LicensingLifecycle with default PENDING (per license-request.entity.ts), so this is correct.

One small style nit: the export { LicenseRequestStatus } … re-export on line 19 is sandwiched between two import groups. Most lint configs and readers prefer all imports first, then re-exports. Moving it below the import block would keep the top of the file uniform without changing behavior.

♻️ Optional reorganization
 import {
   LicensingLifecycle,
   WITHDRAWABLE_STATES,
   REOPENABLE_STATES,
   RESPONDABLE_STATES,
 } from "./licensing-lifecycle.enum";
-
-/** `@deprecated` kept for callers that still reference the old enum */
-export { LicenseRequestStatus } from "./license-request.entity";
 import {
   CreateTrackLicenseDto,
   CreateLicenseRequestDto,
   RespondToLicenseRequestDto,
 } from "./licensing.dto";
 import { LicensingMailService } from "./licensing-mail.service";
 import { LicensingDeliveryQueue } from "./licensing-delivery.queue";
 import { NotificationsService } from "@/notifications/notifications.service";
 import { Track } from "@/tracks/entities/track.entity";
 import { NotificationType } from "@/notifications/notification.entity";
+
+/** `@deprecated` kept for callers that still reference the old enum */
+export { LicenseRequestStatus } from "./license-request.entity";
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/track-listening-right-management/licensing.service.ts` around
lines 11 - 29, Move the re-export "export { LicenseRequestStatus } from
\"./license-request.entity\";" out of the middle of the import group and place
it after the import block (i.e., after the last import such as NotificationType)
so all imports remain grouped together at the top and the lone re-export follows
them; update licensing.service.ts by relocating the LicenseRequestStatus
re-export to just below the import statements.
backend/src/scheduled-releases/scheduled-release-retry.policy.ts (2)

12-20: Default backoffStrategy should be 'exponential'.

Issue #499 calls for replacing fixed-delay semantics with exponential backoff. A 'fixed' default is the opposite of the policy's stated purpose; any future consumer that relies on the default will silently get fixed delay. The service in scheduled-releases.service.ts (line 25) does explicitly pass 'exponential', but the default itself should be the safe, intent-aligned choice.

♻️ Proposed fix
   constructor(
     maxRetries: number = 3,
     baseDelayMs: number = 5000,
-    backoffStrategy: 'exponential' | 'fixed' = 'fixed',
+    backoffStrategy: 'exponential' | 'fixed' = 'exponential',
   ) {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/scheduled-releases/scheduled-release-retry.policy.ts` around
lines 12 - 20, Change the default backoffStrategy in the constructor to
'exponential' so the class aligns with the intended exponential-backoff policy;
update the constructor signature that currently sets backoffStrategy:
'exponential' | 'fixed' = 'fixed' to use = 'exponential', leaving maxRetries and
baseDelayMs defaults unchanged and ensuring any consumers (e.g.,
scheduled-releases.service.ts) that rely on the default now get exponential
behavior.

22-45: Disambiguate attemptNumber semantics across methods.

shouldRetry(attemptNumber, ...) treats the argument as "past-attempt count" (so 0 = first try, blocks when >= maxRetries), while getDelayMs(attemptNumber) / getNextRetryTime(attemptNumber) treat it as a 1-indexed attempt number (formula baseDelayMs * 2^(attemptNumber - 1)). The caller in scheduled-releases.service.ts already compensates by passing release.retryCount to one and release.retryCount + 1 to the other (lines 214 and 256) — but the shared parameter name guarantees future drift. Also, getDelayMs(0) would silently return baseDelayMs / 2 for exponential.

Consider renaming/aligning so the contract is unambiguous, and clamp the exponent to be defensive:

♻️ Proposed fix
 export interface RetryPolicy {
   maxRetries: number;
-  getDelayMs(attemptNumber: number): number;
-  shouldRetry(attemptNumber: number, nextRetryAt: Date | null): boolean;
+  /** Delay before the next attempt, given how many attempts have already been made. */
+  getDelayMs(pastAttempts: number): number;
+  /** Whether another attempt is allowed, given how many attempts have already been made. */
+  shouldRetry(pastAttempts: number, nextRetryAt: Date | null): boolean;
 }
@@
-  getDelayMs(attemptNumber: number): number {
-    if (this.backoffStrategy === 'exponential') {
-      return this.baseDelayMs * Math.pow(2, attemptNumber - 1);
-    }
-    return this.baseDelayMs;
-  }
+  getDelayMs(pastAttempts: number): number {
+    const safeExponent = Math.max(0, pastAttempts);
+    if (this.backoffStrategy === 'exponential') {
+      return this.baseDelayMs * Math.pow(2, safeExponent);
+    }
+    return this.baseDelayMs;
+  }
@@
-  shouldRetry(attemptNumber: number, nextRetryAt: Date | null): boolean {
-    if (attemptNumber >= this.maxRetries) {
+  shouldRetry(pastAttempts: number, nextRetryAt: Date | null): boolean {
+    if (pastAttempts >= this.maxRetries) {
       return false;
     }
@@
-  getNextRetryTime(attemptNumber: number): Date {
-    const delayMs = this.getDelayMs(attemptNumber);
+  getNextRetryTime(pastAttempts: number): Date {
+    const delayMs = this.getDelayMs(pastAttempts);
     return new Date(Date.now() + delayMs);
   }

Caller would then consistently pass release.retryCount (the count of past failures) to all three methods.

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

In `@backend/src/scheduled-releases/scheduled-release-retry.policy.ts` around
lines 22 - 45, The three methods have inconsistent semantics for
"attemptNumber"; rename the parameter to pastAttempts on getDelayMs,
shouldRetry, and getNextRetryTime so all expect the count of prior failures (0 =
first try), update callers to pass release.retryCount everywhere, and change the
exponential formula to use Math.pow(2, Math.max(0, pastAttempts)) (i.e., delay =
baseDelayMs * 2^pastAttempts) to avoid fractional delays and defensively clamp
negative inputs. Ensure shouldRetry still compares pastAttempts against
maxRetries (e.g., if pastAttempts >= maxRetries return false) and
getNextRetryTime uses the new getDelayMs(pastAttempts) result.
backend/src/scheduled-releases/scheduled-releases.service.ts (2)

25-25: Inject the retry policy instead of hardcoding it.

Hardcoding new ScheduledReleaseRetryPolicy(3, 5000, 'exponential') makes retry behavior non-configurable per environment and harder to mock in unit tests (issue #499 explicitly calls out testable retry behavior). Provide it via the Nest DI container — using a token + factory pulling from ConfigService — so production, staging, and tests can each tune maxRetries/baseDelayMs/backoffStrategy without code changes.

♻️ Sketch
-  private readonly retryPolicy = new ScheduledReleaseRetryPolicy(3, 5000, 'exponential');
+  // injected via constructor below
@@
   constructor(
     `@InjectRepository`(ScheduledRelease)
     private scheduledReleaseRepository: Repository<ScheduledRelease>,
     ...
     private readonly dlqService: DlqService,
+    `@Inject`(SCHEDULED_RELEASE_RETRY_POLICY)
+    private readonly retryPolicy: RetryPolicy,
   ) {}

In the module:

{
  provide: SCHEDULED_RELEASE_RETRY_POLICY,
  inject: [ConfigService],
  useFactory: (cfg: ConfigService) =>
    new ScheduledReleaseRetryPolicy(
      cfg.get<number>('SCHEDULED_RELEASE_MAX_RETRIES', 3),
      cfg.get<number>('SCHEDULED_RELEASE_BASE_DELAY_MS', 5000),
      cfg.get<'exponential' | 'fixed'>('SCHEDULED_RELEASE_BACKOFF', 'exponential'),
    ),
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/scheduled-releases/scheduled-releases.service.ts` at line 25,
Replace the hardcoded instantiation of ScheduledReleaseRetryPolicy in
ScheduledReleasesService with a DI-injected instance: remove the "new
ScheduledReleaseRetryPolicy(3, 5000, 'exponential')" assignment and add a
constructor parameter that injects the token SCHEDULED_RELEASE_RETRY_POLICY
(e.g., `@Inject`(SCHEDULED_RELEASE_RETRY_POLICY) private readonly retryPolicy:
ScheduledReleaseRetryPolicy) so the service uses the injected policy; also add a
provider in the module that supplies SCHEDULED_RELEASE_RETRY_POLICY via a
factory using ConfigService to read SCHEDULED_RELEASE_MAX_RETRIES,
SCHEDULED_RELEASE_BASE_DELAY_MS, and SCHEDULED_RELEASE_BACKOFF and construct the
ScheduledReleaseRetryPolicy, enabling environment-driven and testable retry
configuration.

161-176: nextRetryAt gating LGTM; flag latent stuck-state and concurrency risk.

The new filter (sr.nextRetryAt IS NULL OR sr.nextRetryAt <= :now) correctly satisfies #499's gating requirement — new releases (nullable column, default NULL) match on the first run, and failed releases are excluded until their backoff window elapses.

Two pre-existing concerns surface, both worth tracking even if out-of-scope here:

  1. Stuck PUBLISHING records: the cron only excludes FAILED_PERMANENTLY. If a worker crashes mid-publish, the row stays status=PUBLISHING and the idempotency check at lines 204-209 will silently skip it forever. Consider either (a) excluding PUBLISHING here AND providing a sweeper that resets stale PUBLISHING rows past a TTL, or (b) routing all picks through the existing ScheduledReleaseLockService whose claimRelease already encodes lease expiry.
  2. Multi-instance duplicate processing: processReleaseWithRetry reads then mutates without an atomic claim, so two pods running this cron will race. The repo already has ScheduledReleaseLockService.claimRelease(...) (atomic conditional UPDATE) — wiring it in here would make this scheduler exactly-once safe.
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/scheduled-releases/scheduled-releases.service.ts` around lines
161 - 176, The current query correctly gates on nextRetryAt but can leave rows
stuck in status=PUBLISHING and allows duplicate processing across instances;
update the selection and processing flow: (1) add an exclusion for
ReleaseStatus.PUBLISHING in the createQueryBuilder filter (in addition to
FAILED_PERMANENTLY) to avoid permanently skipping crashed publishes, and (2)
before calling processReleaseWithRetry, atomically claim each candidate using
ScheduledReleaseLockService.claimRelease(...) (which does a conditional
UPDATE/lease) and only process releases that were successfully claimed;
alternatively implement a TTL-based sweeper to reset stale PUBLISHING rows if
you cannot claim atomically. Ensure references to nextRetryAt,
ReleaseStatus.PUBLISHING, ScheduledReleaseLockService.claimRelease, and
processReleaseWithRetry are used so the changes are applied in the right
locations.
backend/src/reports/reports.controller.ts (1)

28-35: Surface validation errors and prefer ValidationPipe.

Two related concerns:

  1. The generic BadRequestException('Invalid query parameters') discards every per-field error from class-validator. Callers can't tell which parameter is wrong (e.g., limit=200 vs. status=foo), which hurts both API consumers and debugging.
  2. NestJS already provides ValidationPipe to do plainToInstance + validate with transform: true/whitelist: true, returning a structured 400 with constraint messages. Re-implementing it manually here is non-idiomatic and easy to drift from the global pipe configuration (if any).
♻️ Proposed refactor
-import { Controller, Get, Post, Body, Patch, Param, UseGuards, Query, BadRequestException } from '@nestjs/common';
+import { Controller, Get, Post, Body, Patch, Param, UseGuards, Query, ValidationPipe } from '@nestjs/common';
 import { ReportsService } from './reports.service';
 import { CreateReportDto } from './dto/create-report.dto';
 import { UpdateReportStatusDto } from './dto/update-report-status.dto';
 import { AssignReportDto } from './dto/assign-report.dto';
 import { ReportQueryDto } from './dto/report-query.dto';
 ...
-import { plainToInstance } from 'class-transformer';
-import { validate } from 'class-validator';
@@
   `@Get`()
   `@UseGuards`(RolesGuard)
   `@Roles`(UserRole.ADMIN)
-  async findAll(`@Query`() query: any) {
-    const dto = plainToInstance(ReportQueryDto, query);
-    const errors = await validate(dto);
-    if (errors.length > 0) {
-      throw new BadRequestException('Invalid query parameters');
-    }
-    return this.reportsService.findAll(dto);
+  findAll(
+    `@Query`(new ValidationPipe({ transform: true, whitelist: true, forbidNonWhitelisted: true }))
+    query: ReportQueryDto,
+  ) {
+    return this.reportsService.findAll(query);
   }

If a global ValidationPipe with transform: true is already enabled in main.ts, simply typing @Query() query: ReportQueryDto is enough.

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

In `@backend/src/reports/reports.controller.ts` around lines 28 - 35, The findAll
method is swallowing per-field validation errors by manually calling
plainToInstance + validate and throwing a generic BadRequestException; remove
the manual plainToInstance/validate logic in findAll (and the generic
BadRequestException) and instead accept a typed DTO parameter so Nest's
ValidationPipe can run (change signature to `@Query`() query: ReportQueryDto) or,
if you don't have a global pipe, annotate the handler with `@UsePipes`(new
ValidationPipe({ transform: true, whitelist: true })) to ensure class-validator
errors are returned verbatim; keep the call to this.reportsService.findAll(dto)
but pass the validated/transformed ReportQueryDto.
backend/src/reports/reports.service.ts (1)

26-27: Unused auditLogRepository injection.

auditLogRepository is injected but never referenced in this service — all AdminAuditLog writes happen inside ReportEnforcementService. Drop the injection (and the corresponding import on line 11) to keep the DI graph honest.

♻️ Proposed cleanup
-import { AdminAuditLog } from '../admin/entities/admin-audit-log.entity';
@@
     `@InjectRepository`(Track)
     private tracksRepository: Repository<Track>,
-    `@InjectRepository`(AdminAuditLog)
-    private auditLogRepository: Repository<AdminAuditLog>,
     private enforcementService: ReportEnforcementService,

Note: if you drop this, you can also remove AdminAuditLog from TypeOrmModule.forFeature(...) in reports.module.ts only if ReportEnforcementService's registration of it is sufficient (it is, since they share the same module).

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

In `@backend/src/reports/reports.service.ts` around lines 26 - 27, Remove the
unused injection of AdminAuditLog: delete the private auditLogRepository:
Repository<AdminAuditLog> field and its `@InjectRepository`(AdminAuditLog)
decorator in reports.service.ts and remove the AdminAuditLog import at the top
of that file; then, if ReportEnforcementService already supplies AdminAuditLog
persistence in the same module, also remove AdminAuditLog from
TypeOrmModule.forFeature(...) in reports.module.ts to avoid registering an
unused repository. Ensure no other references to auditLogRepository remain
before committing.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@backend/src/recommendations/recommendation-cache.service.ts`:
- Around line 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.
- Around line 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.
- Around line 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.

In `@backend/src/recommendations/recommendations.service.ts`:
- Around line 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.

In `@backend/src/reports/dto/report-query.dto.ts`:
- Around line 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.
- Around line 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.

In `@backend/src/reports/report-enforcement.service.ts`:
- Around line 48-92: The users/tracks update and the subsequent createAuditLog
must be executed in a single DB transaction so both succeed or both rollback:
change the enforcement flows in applyEnforcement (the block that calls
usersRepository.update or tracksRepository.update and then this.createAuditLog)
to run inside DataSource.transaction (or create a QueryRunner) and use the
transactional EntityManager to perform the update and to save the audit entry
(pass the manager to/create a transactional variant of createAuditLog), and
apply the same pattern in reverseEnforcement so the reversal update and its
audit log use the same transaction/QueryRunner.
- Around line 95-150: The reverseEnforcement method currently doesn't record
which enforcement or report it's reversing and uses a hardcoded reason; update
the reverseEnforcement(enforcement: EnforcementAction, admin: User, ipAddress:
string) signature to accept a reason: string and an optional originalReportId?:
string (or originalAuditLogId?: string), propagate these into the createAuditLog
call (add fields originalReportId/originalAuditLogId and use the provided reason
instead of the hardcoded string), and ensure the created audit log action still
uses `ENFORCEMENT_REVERSED_${enforcement.type}` but now includes the original
identifier in the audit payload so queries can link reversal→original (update
calls in both USER_BANNED and CONTENT_REMOVED branches and the surrounding
logging to include the reason and original id).
- Around line 101-118: In reverseEnforcement, don’t rely on the stale
enforcement.targetUser snapshot to compute previousState/newStatus; re-fetch the
current user from the DB (via usersRepository.findOne/findById or the repository
method used elsewhere) using enforcement.entityId, set previousState based on
that fresh record’s status, compute newStatus from that current status (toggle
BANNED → ACTIVE otherwise keep), then call usersRepository.update and
createAuditLog with previousState and newState; alternatively, if a
previousState was captured at apply-time, use that stored previousState instead
of enforcement.targetUser.
- Around line 49-68: The audit log is being created even when no enforcement
occurred (e.g., mismatched action/entity or missing row). Modify the enforcement
branches (the ReportAction.USER_BANNED + ReportEntityType.USER path that uses
usersRepository.findOne/update and the ReportAction.CONTENT_REMOVED +
ReportEntityType.TRACK path that uses tracksRepository.findOne/update) to set a
boolean flag (e.g., enforcementApplied = true) when an update actually happens
(after usersRepository.update or tracksRepository.update and after setting
previousState/newState), and then before calling createAuditLog(...) return
early or skip creating the audit entry if enforcementApplied is false (or
previousState/newState are still unset). Ensure createAuditLog is only invoked
when an actual change was applied.

In `@backend/src/reports/reports.service.ts`:
- Around line 104-112: The report update currently passes a hardcoded ipAddress
('0.0.0.0') into enforcementService.applyEnforcement which breaks auditing;
change the wiring so the real client IP is threaded from the controller into
ReportsService.updateStatus (or the method handling the update) and then
forwarded into enforcementService.applyEnforcement. Concretely: accept an ip
(e.g., requestIp: string) in the controller (use `@Ip`() or request.ip), add an ip
parameter to ReportsService.updateStatus (or the specific update method) and to
EnforcementService.applyEnforcement, update all call sites to pass the real IP
through, and ensure AdminAuditLog.ipAddress is populated with that value instead
of the hardcoded '0.0.0.0'.

In `@backend/src/tracks/track-file-reaper.service.ts`:
- Around line 91-93: The current query uses .where('id = :id', { id: track.id
}).orWhere('id = :id AND "deletedAt" IS NOT NULL', { id: track.id }) which
collapses to id = :id; change the second clause to an and condition so the
update only affects soft-deleted rows. Replace the orWhere call with an andWhere
(e.g. .andWhere('"deletedAt" IS NOT NULL') or .andWhere('id = :id AND
"deletedAt" IS NOT NULL', { id: track.id })) in the TrackFileReaperService query
so the update is actually constrained to rows where "deletedAt" IS NOT NULL and
avoid duplicate/conflicting parameter bindings.

In `@backend/src/tracks/tracks.service.ts`:
- Around line 240-242: The current QueryBuilder uses .where('id = :id', { id
}).orWhere('id = :id AND "deletedAt" IS NOT NULL', { id }) which collapses to
just id = :id; change this to enforce the soft-delete guard by combining the
predicates (e.g., replace the chain with a single .where('id = :id AND
"deletedAt" IS NOT NULL', { id }) or use .where('id = :id', { id
}).andWhere('"deletedAt" IS NOT NULL') so the update in TracksService targets
only soft-deleted rows.

---

Outside diff comments:
In `@backend/src/reports/reports.service.ts`:
- Around line 100-114: The enforcement call
(enforcementService.applyEnforcement) and the subsequent
reportsRepository.save(report) must be executed atomically to avoid inconsistent
state; wrap the resolvedAt assignment, reportsRepository.save(report) and
enforcementService.applyEnforcement(...) inside a single TypeORM transaction
(use this.dataSource.transaction or a QueryRunner) so both the report update and
the enforcement writes/audit commit or roll back together. Update
enforcementService.applyEnforcement to accept an optional
EntityManager/QueryRunner (or use repositories from the transaction manager) and
call it with that manager inside the transaction block; alternatively obtain
repositories from the transaction manager to save the report (instead of
this.reportsRepository) so all DB writes use the same transactional context.
Ensure you still set report.resolvedAt when updateDto.status is
RESOLVED/DISMISSED and preserve checks for updateDto.action !==
ReportAction.NONE inside the transaction.

In `@backend/src/scheduled-releases/scheduled-releases.service.ts`:
- Around line 251-304: The terminal-failure path in handleReleaseFailure
computes retryCount = release.retryCount + 1 but never persists or passes that
increment into markAsPermanentlyFailed, so DB and DLQ keep the stale count; fix
by either updating the record with retryCount before calling
markAsPermanentlyFailed or changing markAsPermanentlyFailed to accept a
retryCount param and use it when calling scheduledReleaseRepository.update and
dlqService.createEntry (ensure lastError/failure message still references
retryPolicy.maxRetries but retryCount field uses the incremented value). Target
symbols: handleReleaseFailure, markAsPermanentlyFailed,
scheduledReleaseRepository.update, dlqService.createEntry, retryCount,
retryPolicy.maxRetries.

---

Nitpick comments:
In `@backend/src/reports/reports.controller.ts`:
- Around line 28-35: The findAll method is swallowing per-field validation
errors by manually calling plainToInstance + validate and throwing a generic
BadRequestException; remove the manual plainToInstance/validate logic in findAll
(and the generic BadRequestException) and instead accept a typed DTO parameter
so Nest's ValidationPipe can run (change signature to `@Query`() query:
ReportQueryDto) or, if you don't have a global pipe, annotate the handler with
`@UsePipes`(new ValidationPipe({ transform: true, whitelist: true })) to ensure
class-validator errors are returned verbatim; keep the call to
this.reportsService.findAll(dto) but pass the validated/transformed
ReportQueryDto.

In `@backend/src/reports/reports.service.ts`:
- Around line 26-27: Remove the unused injection of AdminAuditLog: delete the
private auditLogRepository: Repository<AdminAuditLog> field and its
`@InjectRepository`(AdminAuditLog) decorator in reports.service.ts and remove the
AdminAuditLog import at the top of that file; then, if ReportEnforcementService
already supplies AdminAuditLog persistence in the same module, also remove
AdminAuditLog from TypeOrmModule.forFeature(...) in reports.module.ts to avoid
registering an unused repository. Ensure no other references to
auditLogRepository remain before committing.

In `@backend/src/scheduled-releases/scheduled-release-retry.policy.ts`:
- Around line 12-20: Change the default backoffStrategy in the constructor to
'exponential' so the class aligns with the intended exponential-backoff policy;
update the constructor signature that currently sets backoffStrategy:
'exponential' | 'fixed' = 'fixed' to use = 'exponential', leaving maxRetries and
baseDelayMs defaults unchanged and ensuring any consumers (e.g.,
scheduled-releases.service.ts) that rely on the default now get exponential
behavior.
- Around line 22-45: The three methods have inconsistent semantics for
"attemptNumber"; rename the parameter to pastAttempts on getDelayMs,
shouldRetry, and getNextRetryTime so all expect the count of prior failures (0 =
first try), update callers to pass release.retryCount everywhere, and change the
exponential formula to use Math.pow(2, Math.max(0, pastAttempts)) (i.e., delay =
baseDelayMs * 2^pastAttempts) to avoid fractional delays and defensively clamp
negative inputs. Ensure shouldRetry still compares pastAttempts against
maxRetries (e.g., if pastAttempts >= maxRetries return false) and
getNextRetryTime uses the new getDelayMs(pastAttempts) result.

In `@backend/src/scheduled-releases/scheduled-releases.service.ts`:
- Line 25: Replace the hardcoded instantiation of ScheduledReleaseRetryPolicy in
ScheduledReleasesService with a DI-injected instance: remove the "new
ScheduledReleaseRetryPolicy(3, 5000, 'exponential')" assignment and add a
constructor parameter that injects the token SCHEDULED_RELEASE_RETRY_POLICY
(e.g., `@Inject`(SCHEDULED_RELEASE_RETRY_POLICY) private readonly retryPolicy:
ScheduledReleaseRetryPolicy) so the service uses the injected policy; also add a
provider in the module that supplies SCHEDULED_RELEASE_RETRY_POLICY via a
factory using ConfigService to read SCHEDULED_RELEASE_MAX_RETRIES,
SCHEDULED_RELEASE_BASE_DELAY_MS, and SCHEDULED_RELEASE_BACKOFF and construct the
ScheduledReleaseRetryPolicy, enabling environment-driven and testable retry
configuration.
- Around line 161-176: The current query correctly gates on nextRetryAt but can
leave rows stuck in status=PUBLISHING and allows duplicate processing across
instances; update the selection and processing flow: (1) add an exclusion for
ReleaseStatus.PUBLISHING in the createQueryBuilder filter (in addition to
FAILED_PERMANENTLY) to avoid permanently skipping crashed publishes, and (2)
before calling processReleaseWithRetry, atomically claim each candidate using
ScheduledReleaseLockService.claimRelease(...) (which does a conditional
UPDATE/lease) and only process releases that were successfully claimed;
alternatively implement a TTL-based sweeper to reset stale PUBLISHING rows if
you cannot claim atomically. Ensure references to nextRetryAt,
ReleaseStatus.PUBLISHING, ScheduledReleaseLockService.claimRelease, and
processReleaseWithRetry are used so the changes are applied in the right
locations.

In `@backend/src/track-listening-right-management/licensing-mail.service.ts`:
- Around line 40-56: The current notifyRequesterOfResponse function treats any
non-APPROVED status as "rejected", which mislabels states like
WITHDRAWN/EXPIRED/PENDING; update notifyRequesterOfResponse to check
request.status explicitly against LicensingLifecycle.APPROVED and
LicensingLifecycle.REJECTED (use those two branches to set statusLabel and
subject) and handle any other states by either skipping sending the email or
throwing/logging an error so sendMail is not invoked for unexpected lifecycle
values; make the change in the notifyRequesterOfResponse method and ensure any
returned/early-exit behavior is clear to the caller.

In `@backend/src/track-listening-right-management/licensing.service.ts`:
- Around line 11-29: Move the re-export "export { LicenseRequestStatus } from
\"./license-request.entity\";" out of the middle of the import group and place
it after the import block (i.e., after the last import such as NotificationType)
so all imports remain grouped together at the top and the lone re-export follows
them; update licensing.service.ts by relocating the LicenseRequestStatus
re-export to just below the import statements.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: df1b6ed3-d205-4071-a84a-536c387738d3

📥 Commits

Reviewing files that changed from the base of the PR and between 4b5110e and bf89e59.

📒 Files selected for processing (16)
  • backend/src/recommendations/recommendation-cache.service.ts
  • backend/src/recommendations/recommendations.module.ts
  • backend/src/recommendations/recommendations.service.ts
  • backend/src/reports/dto/report-query.dto.ts
  • backend/src/reports/report-enforcement.service.ts
  • backend/src/reports/reports.controller.ts
  • backend/src/reports/reports.module.ts
  • backend/src/reports/reports.service.ts
  • backend/src/scheduled-releases/scheduled-release-retry.policy.ts
  • backend/src/scheduled-releases/scheduled-releases.service.ts
  • backend/src/stellar/stellar.service.ts
  • backend/src/tips/tips.service.ts
  • backend/src/track-listening-right-management/licensing-mail.service.ts
  • backend/src/track-listening-right-management/licensing.service.ts
  • backend/src/tracks/track-file-reaper.service.ts
  • backend/src/tracks/tracks.service.ts

Comment on lines +11 to +15
@Injectable()
export class RecommendationCacheService {
private readonly logger = new Logger(RecommendationCacheService.name);
private readonly cacheTtlMs = 3600000;
private cache = new Map<string, CacheEntry>();

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.

Comment on lines +91 to +107
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;
}

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.

Comment on lines +109 to +117
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}`);
}

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.

Comment on lines 34 to 54
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;
}

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.

Comment on lines +18 to +19
@IsOptional()
assignedToId?: string;

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.

Comment on lines +95 to +150
async reverseEnforcement(
enforcement: EnforcementAction,
admin: User,
ipAddress: string,
): Promise<void> {
try {
if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) {
const previousState = { status: enforcement.targetUser.status };
const newStatus = enforcement.targetUser.status === UserStatus.BANNED ? UserStatus.ACTIVE : enforcement.targetUser.status;
await this.usersRepository.update(enforcement.entityId, {
status: newStatus,
});

await this.createAuditLog({
adminId: admin.id,
admin,
action: `ENFORCEMENT_REVERSED_${enforcement.type}`,
entityType: enforcement.entityType,
entityId: enforcement.entityId,
previousState,
newState: { status: newStatus },
reason: 'Enforcement reversal requested',
ipAddress,
});
} else if (
enforcement.type === ReportAction.CONTENT_REMOVED &&
enforcement.targetTrack
) {
const previousState = { isPublic: enforcement.targetTrack.isPublic };
await this.tracksRepository.update(enforcement.entityId, {
isPublic: true,
});

await this.createAuditLog({
adminId: admin.id,
admin,
action: `ENFORCEMENT_REVERSED_${enforcement.type}`,
entityType: enforcement.entityType,
entityId: enforcement.entityId,
previousState,
newState: { isPublic: true },
reason: 'Enforcement reversal requested',
ipAddress,
});
}

this.logger.log(
`Reversed enforcement action ${enforcement.type} for entity ${enforcement.entityId}`,
);
} catch (error) {
this.logger.error(
`Failed to reverse enforcement: ${error.message}`,
);
throw error;
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

Reversal isn't linked to the original enforcement and has a hardcoded reason.

Two related gaps that weaken the audit trail required by #498:

  1. The reversal audit row encodes ENFORCEMENT_REVERSED_<type> in action but stores no reference to the originating audit log id or the originating Report. Querying "which reversal undid which enforcement?" requires fuzzy matching on entityId + timestamps.
  2. reason is hardcoded to 'Enforcement reversal requested' — operators can't record why the reversal was performed.

Consider accepting a reason: string parameter (and ideally an originalReportId / originalAuditLogId) and persisting them in the audit log.

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

In `@backend/src/reports/report-enforcement.service.ts` around lines 95 - 150, The
reverseEnforcement method currently doesn't record which enforcement or report
it's reversing and uses a hardcoded reason; update the
reverseEnforcement(enforcement: EnforcementAction, admin: User, ipAddress:
string) signature to accept a reason: string and an optional originalReportId?:
string (or originalAuditLogId?: string), propagate these into the createAuditLog
call (add fields originalReportId/originalAuditLogId and use the provided reason
instead of the hardcoded string), and ensure the created audit log action still
uses `ENFORCEMENT_REVERSED_${enforcement.type}` but now includes the original
identifier in the audit payload so queries can link reversal→original (update
calls in both USER_BANNED and CONTENT_REMOVED branches and the surrounding
logging to include the reason and original id).

Comment on lines +101 to +118
if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) {
const previousState = { status: enforcement.targetUser.status };
const newStatus = enforcement.targetUser.status === UserStatus.BANNED ? UserStatus.ACTIVE : enforcement.targetUser.status;
await this.usersRepository.update(enforcement.entityId, {
status: newStatus,
});

await this.createAuditLog({
adminId: admin.id,
admin,
action: `ENFORCEMENT_REVERSED_${enforcement.type}`,
entityType: enforcement.entityType,
entityId: enforcement.entityId,
previousState,
newState: { status: newStatus },
reason: 'Enforcement reversal requested',
ipAddress,
});

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

reverseEnforcement reasons over a stale targetUser snapshot.

enforcement.targetUser is captured before the ban update (line 54), so its .status reflects the pre-ban state. The check enforcement.targetUser.status === UserStatus.BANNED ? UserStatus.ACTIVE : enforcement.targetUser.status is therefore effectively dead in the BANNED branch, and the "previousState" written to the reversal audit log records the pre-ban status, not the current DB state. If anything has changed in between (re-ban, manual admin intervention), reversal silently drifts.

Re-fetch the user from the DB and decide based on its current state, or restore from a stored previousState captured at apply-time.

🛡️ Suggested fix
-      if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) {
-        const previousState = { status: enforcement.targetUser.status };
-        const newStatus = enforcement.targetUser.status === UserStatus.BANNED ? UserStatus.ACTIVE : enforcement.targetUser.status;
-        await this.usersRepository.update(enforcement.entityId, {
-          status: newStatus,
-        });
+      if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) {
+        const current = await this.usersRepository.findOne({ where: { id: enforcement.entityId } });
+        if (!current) return;
+        const previousState = { status: current.status };
+        const newStatus =
+          current.status === UserStatus.BANNED ? UserStatus.ACTIVE : current.status;
+        await this.usersRepository.update(enforcement.entityId, { status: newStatus });
📝 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
if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) {
const previousState = { status: enforcement.targetUser.status };
const newStatus = enforcement.targetUser.status === UserStatus.BANNED ? UserStatus.ACTIVE : enforcement.targetUser.status;
await this.usersRepository.update(enforcement.entityId, {
status: newStatus,
});
await this.createAuditLog({
adminId: admin.id,
admin,
action: `ENFORCEMENT_REVERSED_${enforcement.type}`,
entityType: enforcement.entityType,
entityId: enforcement.entityId,
previousState,
newState: { status: newStatus },
reason: 'Enforcement reversal requested',
ipAddress,
});
if (enforcement.type === ReportAction.USER_BANNED && enforcement.targetUser) {
const current = await this.usersRepository.findOne({ where: { id: enforcement.entityId } });
if (!current) return;
const previousState = { status: current.status };
const newStatus =
current.status === UserStatus.BANNED ? UserStatus.ACTIVE : current.status;
await this.usersRepository.update(enforcement.entityId, { status: newStatus });
await this.createAuditLog({
adminId: admin.id,
admin,
action: `ENFORCEMENT_REVERSED_${enforcement.type}`,
entityType: enforcement.entityType,
entityId: enforcement.entityId,
previousState,
newState: { status: newStatus },
reason: 'Enforcement reversal requested',
ipAddress,
});
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/reports/report-enforcement.service.ts` around lines 101 - 118, In
reverseEnforcement, don’t rely on the stale enforcement.targetUser snapshot to
compute previousState/newStatus; re-fetch the current user from the DB (via
usersRepository.findOne/findById or the repository method used elsewhere) using
enforcement.entityId, set previousState based on that fresh record’s status,
compute newStatus from that current status (toggle BANNED → ACTIVE otherwise
keep), then call usersRepository.update and createAuditLog with previousState
and newState; alternatively, if a previousState was captured at apply-time, use
that stored previousState instead of enforcement.targetUser.

Comment on lines +104 to 112
if (updateDto.action && updateDto.action !== ReportAction.NONE) {
const ipAddress = '0.0.0.0';
await this.enforcementService.applyEnforcement(
report,
updateDto.action,
admin,
ipAddress,
);
}

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

Hardcoded ipAddress = '0.0.0.0' defeats the audit trail's purpose.

Issue #498's acceptance criterion is that every enforcement action is auditable. With a hardcoded '0.0.0.0', the AdminAuditLog.ipAddress column captures no actionable forensic information — every reversal/audit query will see the same value. The controller currently doesn't propagate the request IP at all.

Thread the real client IP from the controller (e.g., via @Ip() or request.ip) down to updateStatus and into applyEnforcement.

🛡️ Suggested wiring
// reports.controller.ts
   `@Patch`(':id/status')
   `@UseGuards`(RolesGuard)
   `@Roles`(UserRole.ADMIN)
   updateStatus(
     `@Param`('id') id: string,
     `@Body`() updateDto: UpdateReportStatusDto,
     `@CurrentUser`() admin: User,
+    `@Ip`() ip: string,
   ) {
-    return this.reportsService.updateStatus(id, updateDto, admin);
+    return this.reportsService.updateStatus(id, updateDto, admin, ip);
   }
// reports.service.ts
-  async updateStatus(id: string, updateDto: UpdateReportStatusDto, admin: User): Promise<Report> {
+  async updateStatus(id: string, updateDto: UpdateReportStatusDto, admin: User, ipAddress: string): Promise<Report> {
@@
-    if (updateDto.action && updateDto.action !== ReportAction.NONE) {
-      const ipAddress = '0.0.0.0';
-      await this.enforcementService.applyEnforcement(
-        report,
-        updateDto.action,
-        admin,
-        ipAddress,
-      );
-    }
+    if (updateDto.action && updateDto.action !== ReportAction.NONE) {
+      await this.enforcementService.applyEnforcement(report, updateDto.action, admin, ipAddress);
+    }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/reports/reports.service.ts` around lines 104 - 112, The report
update currently passes a hardcoded ipAddress ('0.0.0.0') into
enforcementService.applyEnforcement which breaks auditing; change the wiring so
the real client IP is threaded from the controller into
ReportsService.updateStatus (or the method handling the update) and then
forwarded into enforcementService.applyEnforcement. Concretely: accept an ip
(e.g., requestIp: string) in the controller (use `@Ip`() or request.ip), add an ip
parameter to ReportsService.updateStatus (or the specific update method) and to
EnforcementService.applyEnforcement, update all call sites to pass the real IP
through, and ensure AdminAuditLog.ipAddress is populated with that value instead
of the hardcoded '0.0.0.0'.

Comment on lines 91 to 93
.where('id = :id', { id: track.id })
.withDeleted()
.orWhere('id = :id AND "deletedAt" IS NOT NULL', { id: track.id })
.execute();

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

Same orWhere logic flaw — predicate is redundant.

(id = :id) OR (id = :id AND "deletedAt" IS NOT NULL) reduces to id = :id, so this update is not actually constrained to soft-deleted rows. It only works correctly today because the surrounding SELECT (lines 62-69) already filtered by track.deletedAt IS NOT NULL. If that upstream guarantee ever changes, the reaper would clear filename on live rows. Use andWhere so the predicate is real.

🛠 Proposed fix
         await this.trackRepo
           .createQueryBuilder()
           .update(Track)
           .set({ filename: null as unknown as string })
-          .where('id = :id', { id: track.id })
-          .orWhere('id = :id AND "deletedAt" IS NOT NULL', { id: track.id })
+          .where('id = :id', { id: track.id })
+          .andWhere('"deletedAt" IS NOT NULL')
           .execute();
📝 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
.where('id = :id', { id: track.id })
.withDeleted()
.orWhere('id = :id AND "deletedAt" IS NOT NULL', { id: track.id })
.execute();
await this.trackRepo
.createQueryBuilder()
.update(Track)
.set({ filename: null as unknown as string })
.where('id = :id', { id: track.id })
.andWhere('"deletedAt" IS NOT NULL')
.execute();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tracks/track-file-reaper.service.ts` around lines 91 - 93, The
current query uses .where('id = :id', { id: track.id }).orWhere('id = :id AND
"deletedAt" IS NOT NULL', { id: track.id }) which collapses to id = :id; change
the second clause to an and condition so the update only affects soft-deleted
rows. Replace the orWhere call with an andWhere (e.g. .andWhere('"deletedAt" IS
NOT NULL') or .andWhere('id = :id AND "deletedAt" IS NOT NULL', { id: track.id
})) in the TrackFileReaperService query so the update is actually constrained to
rows where "deletedAt" IS NOT NULL and avoid duplicate/conflicting parameter
bindings.

Comment on lines 240 to 242
.where('id = :id', { id })
.withDeleted()
.orWhere('id = :id AND "deletedAt" IS NOT NULL', { id })
.execute();

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:

Does TypeORM 0.3.x QueryBuilder update() automatically exclude soft-deleted rows or require withDeleted()?

💡 Result:

No, TypeORM 0.3.x QueryBuilder update does not automatically exclude soft-deleted rows. It requires calling withDeleted to include soft-deleted rows in the update target if needed, but by default excludes them like SELECT queries. Soft delete filtering (excluding rows where deletedAt IS NOT NULL) is automatically applied in QueryBuilder for SELECT, find* methods, and repository methods when the entity has @DeleteDateColumn. This is implemented in the query builder's where conditions for read operations. For update via UpdateQueryBuilder, the documentation does not mention automatic soft-delete filtering, and source code snippets for UpdateQueryBuilder do not show automatic addition of deletedAt IS NULL. Users must manually add .where("deletedAt IS NULL") if they want to exclude soft-deleted rows. withDeleted disables the soft-delete filter, allowing updates to include soft-deleted rows. This behavior is consistent in 0.3.x as seen in recent releases (e.g., 0.3.23 in 2025) and source code. To exclude soft-deleted rows in update, add explicit where condition. To include them, use .withDeleted.

Citations:


🏁 Script executed:

# Find and examine the tracks.service.ts file
fd -type f -name "tracks.service.ts" | head -5

Repository: OlufunbiIK/tip-tune

Length of output: 234


🏁 Script executed:

fd "tracks.service.ts"

Repository: OlufunbiIK/tip-tune

Length of output: 100


🏁 Script executed:

# Read the file to see the context around lines 240-242
cat -n backend/src/tracks/tracks.service.ts | sed -n '230,255p'

Repository: OlufunbiIK/tip-tune

Length of output: 1253


🏁 Script executed:

# See the full remove() method, especially Phase 1
cat -n backend/src/tracks/tracks.service.ts | sed -n '200,250p'

Repository: OlufunbiIK/tip-tune

Length of output: 2136


🏁 Script executed:

# Check the findOne() method to understand soft-delete filtering
cat -n backend/src/tracks/tracks.service.ts | sed -n '190,210p'

Repository: OlufunbiIK/tip-tune

Length of output: 688


orWhere makes the soft-delete predicate a no-op.

The resulting SQL is WHERE (id = :id) OR (id = :id AND "deletedAt" IS NOT NULL), which simplifies to just id = :id — so the "only update soft-deleted rows" guarantee is not enforced. This currently happens to work only because Phase 1 just soft-deleted it; any future reordering or re-use of this pattern will silently update live rows. Use andWhere (or merge into the initial where) to make the predicate meaningful.

🛠 Proposed fix
           await this.tracksRepository
             .createQueryBuilder()
             .update(Track)
             .set({ filename: null as unknown as string })
-            .where('id = :id', { id })
-            .orWhere('id = :id AND "deletedAt" IS NOT NULL', { id })
+            .where('id = :id', { id })
+            .andWhere('"deletedAt" IS NOT NULL')
             .execute();

Note: TypeORM's update() QueryBuilder does not auto-filter by deletedAt (that filter applies to SELECTs unless .withDeleted() is used), so this predicate is the only thing gating the update to soft-deleted rows.

📝 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
.where('id = :id', { id })
.withDeleted()
.orWhere('id = :id AND "deletedAt" IS NOT NULL', { id })
.execute();
.where('id = :id', { id })
.andWhere('"deletedAt" IS NOT NULL')
.execute();
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@backend/src/tracks/tracks.service.ts` around lines 240 - 242, The current
QueryBuilder uses .where('id = :id', { id }).orWhere('id = :id AND "deletedAt"
IS NOT NULL', { id }) which collapses to just id = :id; change this to enforce
the soft-delete guard by combining the predicates (e.g., replace the chain with
a single .where('id = :id AND "deletedAt" IS NOT NULL', { id }) or use
.where('id = :id', { id }).andWhere('"deletedAt" IS NOT NULL') so the update in
TracksService targets only soft-deleted rows.

@OlufunbiIK
OlufunbiIK merged commit a46f986 into Tip-tune-org:main Apr 25, 2026
1 of 2 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants