Skip to content

Redis implementation in tracking-microservice - #136

Open
abhaykalshetti-gif wants to merge 3 commits into
tekdi:mainfrom
abhaykalshetti-gif:redis-layer
Open

Redis implementation in tracking-microservice#136
abhaykalshetti-gif wants to merge 3 commits into
tekdi:mainfrom
abhaykalshetti-gif:redis-layer

Conversation

@abhaykalshetti-gif

Copy link
Copy Markdown

No description provided.

@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@coderabbitai

coderabbitai Bot commented Jul 31, 2026

Copy link
Copy Markdown
📝 Walkthrough

Summary by CodeRabbit

  • New Features

    • Added Redis-backed and in-memory caching options for improved response performance.
    • Added caching for tracking assessments, content, user certificates, and related searches.
    • Added cache health information to the /health endpoint.
    • Added configurable expiration, namespace controls, resilience, and cache metrics.
  • Bug Fixes

    • Cache entries are now invalidated after relevant data changes, helping prevent stale results.
    • Cache failures automatically fall back to retrieving current data.

Walkthrough

The change adds a configurable Redis or memory cache layer. It centralizes cache reads, invalidation, health, metrics, and failure handling. Assessment, content, user-certificate, and certificate services now use cache-backed loaders and tenant-scoped invalidation.

Changes

Shared caching rollout

Layer / File(s) Summary
Cache contracts, providers, and application wiring
src/cache/*, src/app.module.ts, src/app.controller.ts, package.json, caching-strategy.md
Adds cache configuration, key hashing, provider implementations, module wiring, Redis dependencies, the /health cache response, and caching documentation.
Cache service behavior and validation
src/cache/cache.service.ts, src/cache/cache.service.spec.ts
Adds versioned reads, invalidation, cacheability checks, timeouts, circuit breaking, metrics, health reporting, and tests for hit, miss, bypass, failure, and namespace behavior.
Assessment cache migration and invalidation
src/modules/tracking_assessment/tracking_assessment.service.ts, src/modules/tracking_assessment/tracking_assessment.controller.ts
Uses cache-backed assessment reads and status searches with tenant-scoped hashed keys. Invalidates relevant caches after create, update, and delete operations.
Content and certificate cache integration
src/modules/tracking_content/*, src/modules/user_certificate/user_certificate.service..ts, src/modules/certificate/certificate.service.ts
Caches content and certificate-related tracking queries. Adds tenant-scoped invalidation after content, enrollment, status, certificate, and import writes. Removes the previous cache interceptors.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant TrackingService
  participant CacheService
  participant CacheStore
  participant Database
  TrackingService->>CacheService: getOrLoad(namespace, key, loader)
  CacheService->>CacheStore: read version and cache entry
  CacheStore-->>CacheService: cache hit or miss
  CacheService->>Database: execute loader on miss or bypass
  Database-->>CacheService: tracking result
  CacheService->>CacheStore: write cacheable result
  CacheService-->>TrackingService: return result
  TrackingService->>CacheService: invalidate(namespace)
  CacheService->>CacheStore: increment namespace version
Loading
🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 0.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Description check ❓ Inconclusive No pull request description was provided, so the changes and implementation scope are not documented. Add a concise description that summarizes the Redis cache implementation, affected modules, configuration, and testing.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed The title clearly identifies the main change: adding Redis caching support to the tracking microservice.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Warning

There were issues while running some tools. Please review the errors and either fix the tool's configuration or disable the tool if it's a critical failure.

🔧 ESLint

If the error stems from missing dependencies, add them to the package.json file. For unrecoverable errors (e.g., due to private dependencies), disable the tool in the CodeRabbit configuration.

package.json

Parsing error: ESLint was configured to run on <tsconfigRootDir>/package.json using parserOptions.project: /tsconfig.json
The extension for the file (.json) is non-standard. You should add parserOptions.extraFileExtensions to your config.


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.

@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: 10

Caution

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

⚠️ Outside diff range comments (1)
src/modules/tracking_content/tracking_content.service.ts (1)

443-527: 🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

Parameterize the IN clauses instead of interpolating request values.

Lines 453-481 build contentId_text, courseId_text, and unitId_text by concatenating values from searchFilter directly into the SQL text at lines 505-507. A caller controls these array values, so a string element that contains a quote alters the statement. This is a SQL injection path on a public search endpoint. The relocation into the cached loader keeps the defect.

Two further gaps exist in the same block:

  • If any array is empty, the query produces IN () and Postgres rejects the statement.
  • If searchFilter.contentId, courseId, unitId, or userId is missing, Line 454 dereferences undefined.length and throws.

Use array parameters with = ANY($n), as the searchStatusCourseTracking loader already does at Line 743.

🔒 Proposed fix
-        loader: async () => {
-          let contentId_text = '';
-          for (let i = 0; i < contentIdArray.length; i++) {
-            let contentId = contentIdArray[i];
-            if (i == 0) {
-              contentId_text = `${contentId_text}'${contentId}'`;
-            } else {
-              contentId_text = `${contentId_text},'${contentId}'`;
-            }
-          }
-          //courseId
-          let courseId_text = '';
-          for (let i = 0; i < courseIdArray.length; i++) {
-            let courseId = courseIdArray[i];
-            if (i == 0) {
-              courseId_text = `${courseId_text}'${courseId}'`;
-            } else {
-              courseId_text = `${courseId_text},'${courseId}'`;
-            }
-          }
-          //unitId
-          let unitId_text = '';
-          for (let i = 0; i < unitIdArray.length; i++) {
-            let unitId = unitIdArray[i];
-            if (i == 0) {
-              unitId_text = `${unitId_text}'${unitId}'`;
-            } else {
-              unitId_text = `${unitId_text},'${unitId}'`;
-            }
-          }
+        loader: async () => {
           const loaded = [];
           for (let ii = 0; ii < userIdArray.length; ii++) {
-            let userId = userIdArray[ii];
+            const userId = userIdArray[ii];
             const result = await this.dataSource.query(
               `WITH latest_content AS (
                   ...
                       "userId" = $1
-                      AND "courseId" IN (${courseId_text})
-                      AND "unitId" IN (${unitId_text})
-                      AND "contentId" IN (${contentId_text})
-                      AND "tenantId" = $2
+                      AND "courseId" = ANY($2)
+                      AND "unitId" = ANY($3)
+                      AND "contentId" = ANY($4)
+                      AND "tenantId" = $5
               )
               ...`,
-              [userId, tenantId],
+              [userId, courseIdArray, unitIdArray, contentIdArray, tenantId],
             );

Add array validation before the loader, in the same style as searchStatusCourseTracking lines 614-628.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_content/tracking_content.service.ts` around lines 443 -
527, Update the cached loader around output_result to validate contentIdArray,
courseIdArray, unitIdArray, and userIdArray before accessing their lengths,
returning the established empty-result behavior for missing or empty arrays as
in searchStatusCourseTracking. Remove the interpolated contentId_text,
courseId_text, and unitId_text SQL fragments, bind the arrays as query
parameters, and replace each IN clause with = ANY($n), preserving the existing
user and tenant filtering.
🧹 Nitpick comments (5)
src/modules/tracking_content/tracking_content.service.ts (1)

1345-1348: 🚀 Performance & Scalability | 🔵 Trivial | 💤 Low value

Scope the courseinprogress namespace by tenant.

The namespace at Line 1346 has no tenant segment, and the key at Line 1347 hashes only userIdArray. Every tenant write to content tracking invalidates this single namespace, so one tenant's writes drop cached entries for all tenants. Tenant-scope the namespace to limit invalidation blast radius. courseInProgress reads no tenant header today, so this change also requires a tenant source in this method.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_content/tracking_content.service.ts` around lines 1345 -
1348, Update the courseInProgress cache lookup in the surrounding
tracking-content method to obtain the current tenant from the method’s
established request/context source, then include that tenant identifier in the
courseinprogress namespace (and any required cache key inputs). Preserve
user-specific caching while ensuring invalidation for one tenant cannot affect
another.
src/cache/cache.constants.ts (1)

26-29: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Guard against non-numeric env values for timing configs.

Number(get(key) ?? default) does not fall back to the default when get(key) returns an empty string or a non-numeric value. In that case Number(...) returns NaN. NaN then flows into setTimeout calls in cache.service.ts, which treats a NaN delay as 0ms, causing every cache operation to time out immediately and silently degrade caching to "always miss" without any visible error.

Add a Number.isFinite fallback for each numeric field.

🛠️ Proposed fix
+function num(value: unknown, fallback: number): number {
+  const n = Number(value);
+  return Number.isFinite(n) ? n : fallback;
+}
+
 export function loadCacheConfig(get: (key: string) => any): CacheConfig {
   const disabledNamespacesRaw: string = get('CACHE_DISABLED_NAMESPACES') || '';
   return {
     enabled: String(get('CACHE_ENABLED') ?? 'false').toLowerCase() === 'true',
     provider: (get('CACHE_PROVIDER') || 'memory') as 'memory' | 'redis',
     redisUrl: get('REDIS_URL'),
     keyPrefix: get('CACHE_KEY_PREFIX') || 'tms',
     disabledNamespaces: disabledNamespacesRaw
       .split(',')
       .map((s) => s.trim())
       .filter((s) => s.length > 0),
-    opTimeoutMs: Number(get('CACHE_OP_TIMEOUT_MS') ?? 150),
-    cbFailures: Number(get('CACHE_CB_FAILURES') ?? 5),
-    cbCooldownMs: Number(get('CACHE_CB_COOLDOWN_MS') ?? 30000),
-    metricsIntervalMs: Number(get('CACHE_METRICS_INTERVAL_MS') ?? 60000),
+    opTimeoutMs: num(get('CACHE_OP_TIMEOUT_MS'), 150),
+    cbFailures: num(get('CACHE_CB_FAILURES'), 5),
+    cbCooldownMs: num(get('CACHE_CB_COOLDOWN_MS'), 30000),
+    metricsIntervalMs: num(get('CACHE_METRICS_INTERVAL_MS'), 60000),
   };
 }
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/cache.constants.ts` around lines 26 - 29, Update the numeric cache
configuration fields opTimeoutMs, cbFailures, cbCooldownMs, and
metricsIntervalMs to validate the parsed values with Number.isFinite and use
their existing defaults when parsing yields NaN or another non-finite value,
including empty or invalid environment values.
src/cache/cache.service.spec.ts (1)

35-39: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Add a regression test for non-serializable loader results.

Once writeCacheEntry in cache.service.ts is fixed to guard JSON.stringify, add a test where the loader resolves with a circular-reference object (or a value containing a BigInt) and assert that getOrLoad still resolves with that result instead of rejecting.

Do you want me to draft this test case?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/cache.service.spec.ts` around lines 35 - 39, Add a regression test
in the CacheService test suite that configures the loader used by getOrLoad to
resolve with a non-serializable value, such as a circular-reference object or
BigInt, and assert that getOrLoad resolves with the original result rather than
rejecting. Keep the existing afterEach cleanup and test the behavior through the
public getOrLoad path.
package.json (1)

33-33: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Remove the stale cache dependencies.

cache-manager-redis-yet is unused and redis-cache.store.ts imports redis directly. The local cache abstraction is an empty CacheModule, while @nestjs/cache-manager and cache-manager-memory-store are still declared in both package.json and package-lock.json. Drop these unused dependencies and lock entries if memory caching is no longer needed.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@package.json` at line 33, Remove the unused cache dependencies from
package.json, including cache-manager-redis-yet, `@nestjs/cache-manager`, and
cache-manager-memory-store, and regenerate package-lock.json so their stale lock
entries are removed. Preserve the direct redis dependency used by
redis-cache.store.ts and verify no remaining code requires the removed packages.
src/modules/tracking_assessment/tracking_assessment.service.ts (1)

671-805: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Numerous prettier and prefer-const violations in the relocated cache-loader code.

Static analysis flags a large number of prettier/prettier indentation mismatches and prefer-const violations across Lines 671-805 and 815-865 (for example, contentIdArray, courseIdArray, unitIdArray, userIdArray, temp_result, maxMark, scoreMark, percentage, temp_obj, and others are never reassigned but declared with let). This code was relocated into CacheService.getOrLoad loader closures, and the indentation was not adjusted to match, and the let declarations were not updated. Run the formatter and switch flagged let declarations to const to keep the lint pipeline clean.

Also applies to: 815-865

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_assessment/tracking_assessment.service.ts` around lines
671 - 805, Format the relocated cache-loader code in the tracking assessment
service, including the ranges around the getOrLoad loader and adjacent logic, to
satisfy Prettier indentation. In the same code, change every variable that is
never reassigned—such as contentIdArray, courseIdArray, unitIdArray,
userIdArray, temp_result, maxMark, scoreMark, percentage, and temp_obj—from let
to const, while preserving variables that are reassigned.

Source: Linters/SAST tools

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@src/cache/cache.module.ts`:
- Around line 13-24: Update the factory in the cache provider configuration to
check config.enabled before entering the Redis branch. When caching is disabled,
return MemoryCacheStore without requiring REDIS_URL or constructing
RedisCacheStore; preserve the existing Redis validation and selection behavior
when config.enabled is true.

In `@src/cache/cache.service.ts`:
- Around line 146-152: Guard serialization in writeCacheEntry by moving
JSON.stringify(result) into a try/catch before invoking withTimeout or
store.set. On serialization failure, call onOpFailure and return without
throwing, preserving the successful getOrLoad result while retaining the
existing async failure handling for cache writes.

In `@src/cache/memory-cache.store.ts`:
- Around line 32-37: Update MemoryCacheStore.incr so the read-modify-write
operation executes synchronously without awaits between reading and writing the
key, ensuring concurrent calls cannot lose increments. Preserve the existing
numeric parsing, default value, stored string format, and returned next value.

In `@src/modules/tracking_assessment/tracking_assessment.service.ts`:
- Around line 336-340: Update the create flow in createAssessmentTracking to
invalidate the per-record assessmentread cache key for the client-supplied
assessmentTrackingId in addition to the existing tenant-level assessment cache
namespace, ensuring newly created records do not serve a cached missing result.
- Around line 671-747: Update searchStatusAssessmentTracking to remove
courseId_text, unitId_text, and contentId_text string construction; use
parameterized array predicates with = ANY(...::text[]) or individually numbered
placeholders, and pass the raw array values through dataSource.query parameters.
Preserve the existing userId and tenantId bindings while ensuring courseId,
unitId, and contentId values are never interpolated into the SQL.

In `@src/modules/tracking_content/tracking_content.service.ts`:
- Around line 651-670: Run Prettier on the new dashboard loader code around the
certificateResults, statusMap, and attemptMap processing in the tracking
service, correcting the reported formatting violations on the query and map
lines while preserving the existing logic.
- Around line 310-314: Add the `contentread:${contentTrackingId}` entity
namespace to the invalidation keys used by `createContentTracking` after it
updates or creates tracking data, alongside the existing content and course
namespaces, so `getContentTrackingDetails` does not serve stale cached details.
- Around line 632-705: The assessment tracking write paths must also invalidate
the course dashboard cache because getOrLoad caches attempt_count under
course:${tenantId}. Update createAssessmentTracking, deleteAssessmentTracking,
and any assessment-tracking update path that changes a course/user’s attempts to
invalidate course:${tenantId} alongside assessment:${tenantId}, reusing the
existing tenant-scoped cache invalidation mechanism.

In `@src/modules/user_certificate/user_certificate.service`..ts:
- Around line 245-262: Restrict the filter iteration in the
UserCourseCertificate query-building flow to an explicit allowlist of valid
UserCourseCertificate column names before interpolating key into SQL or
parameter names. Skip or reject any unapproved key, while preserving the
existing array IN and scalar equality handling for allowed keys.
- Around line 334-338: Update the cache invalidation in the import flow around
the user certificate record lookup and save to use the saved record’s tenantId
(from createUserCertificateDto) rather than request.tenantId. Also invalidate
the corresponding course:${tenantId} namespace, matching enrollUserForCourse, so
both usercert and course caches are cleared for the record’s owning tenant.

---

Outside diff comments:
In `@src/modules/tracking_content/tracking_content.service.ts`:
- Around line 443-527: Update the cached loader around output_result to validate
contentIdArray, courseIdArray, unitIdArray, and userIdArray before accessing
their lengths, returning the established empty-result behavior for missing or
empty arrays as in searchStatusCourseTracking. Remove the interpolated
contentId_text, courseId_text, and unitId_text SQL fragments, bind the arrays as
query parameters, and replace each IN clause with = ANY($n), preserving the
existing user and tenant filtering.

---

Nitpick comments:
In `@package.json`:
- Line 33: Remove the unused cache dependencies from package.json, including
cache-manager-redis-yet, `@nestjs/cache-manager`, and cache-manager-memory-store,
and regenerate package-lock.json so their stale lock entries are removed.
Preserve the direct redis dependency used by redis-cache.store.ts and verify no
remaining code requires the removed packages.

In `@src/cache/cache.constants.ts`:
- Around line 26-29: Update the numeric cache configuration fields opTimeoutMs,
cbFailures, cbCooldownMs, and metricsIntervalMs to validate the parsed values
with Number.isFinite and use their existing defaults when parsing yields NaN or
another non-finite value, including empty or invalid environment values.

In `@src/cache/cache.service.spec.ts`:
- Around line 35-39: Add a regression test in the CacheService test suite that
configures the loader used by getOrLoad to resolve with a non-serializable
value, such as a circular-reference object or BigInt, and assert that getOrLoad
resolves with the original result rather than rejecting. Keep the existing
afterEach cleanup and test the behavior through the public getOrLoad path.

In `@src/modules/tracking_assessment/tracking_assessment.service.ts`:
- Around line 671-805: Format the relocated cache-loader code in the tracking
assessment service, including the ranges around the getOrLoad loader and
adjacent logic, to satisfy Prettier indentation. In the same code, change every
variable that is never reassigned—such as contentIdArray, courseIdArray,
unitIdArray, userIdArray, temp_result, maxMark, scoreMark, percentage, and
temp_obj—from let to const, while preserving variables that are reassigned.

In `@src/modules/tracking_content/tracking_content.service.ts`:
- Around line 1345-1348: Update the courseInProgress cache lookup in the
surrounding tracking-content method to obtain the current tenant from the
method’s established request/context source, then include that tenant identifier
in the courseinprogress namespace (and any required cache key inputs). Preserve
user-specific caching while ensuring invalidation for one tenant cannot affect
another.
🪄 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: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 36917a8e-06f2-4178-bca0-2323c0232bd2

📥 Commits

Reviewing files that changed from the base of the PR and between 7a2f3e9 and 65d3f84.

⛔ Files ignored due to path filters (1)
  • package-lock.json is excluded by !**/package-lock.json
📒 Files selected for processing (19)
  • caching-strategy.md
  • package.json
  • src/app.controller.ts
  • src/app.module.ts
  • src/cache/cache-key.util.ts
  • src/cache/cache-store.interface.ts
  • src/cache/cache.constants.ts
  • src/cache/cache.module.ts
  • src/cache/cache.service.spec.ts
  • src/cache/cache.service.ts
  • src/cache/memory-cache.store.ts
  • src/cache/redis-cache.store.ts
  • src/modules/ai_assessment/ai_assessment.service.ts
  • src/modules/certificate/certificate.service.ts
  • src/modules/tracking_assessment/tracking_assessment.controller.ts
  • src/modules/tracking_assessment/tracking_assessment.service.ts
  • src/modules/tracking_content/tracking_content.controller.ts
  • src/modules/tracking_content/tracking_content.service.ts
  • src/modules/user_certificate/user_certificate.service..ts
💤 Files with no reviewable changes (3)
  • src/modules/tracking_assessment/tracking_assessment.controller.ts
  • src/modules/tracking_content/tracking_content.controller.ts
  • src/modules/ai_assessment/ai_assessment.service.ts

Comment thread src/cache/cache.module.ts
Comment on lines +13 to +24
useFactory: (configService: ConfigService) => {
const config = loadCacheConfig((k) => configService.get(k));
if (config.provider === 'redis') {
if (!config.redisUrl) {
throw new Error(
'REDIS_URL is required when CACHE_PROVIDER=redis',
);
}
return new RedisCacheStore(config.redisUrl);
}
return new MemoryCacheStore();
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Honor CACHE_ENABLED before requiring Redis configuration.

The factory selects RedisCacheStore and requires REDIS_URL whenever CACHE_PROVIDER=redis, regardless of config.enabled. caching-strategy.md documents CACHE_ENABLED as the master on/off switch, but disabling the cache still fails application bootstrap when REDIS_URL is missing, and still opens an unnecessary Redis connection attempt that CacheService never uses once caching is bypassed at line 52 of cache.service.ts.

Gate the Redis branch on config.enabled so a disabled cache never requires Redis configuration.

🛠️ Proposed fix
       useFactory: (configService: ConfigService) => {
         const config = loadCacheConfig((k) => configService.get(k));
-        if (config.provider === 'redis') {
+        if (config.enabled && config.provider === 'redis') {
           if (!config.redisUrl) {
             throw new Error(
               'REDIS_URL is required when CACHE_PROVIDER=redis',
             );
           }
           return new RedisCacheStore(config.redisUrl);
         }
         return new MemoryCacheStore();
       },
📝 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
useFactory: (configService: ConfigService) => {
const config = loadCacheConfig((k) => configService.get(k));
if (config.provider === 'redis') {
if (!config.redisUrl) {
throw new Error(
'REDIS_URL is required when CACHE_PROVIDER=redis',
);
}
return new RedisCacheStore(config.redisUrl);
}
return new MemoryCacheStore();
},
useFactory: (configService: ConfigService) => {
const config = loadCacheConfig((k) => configService.get(k));
if (config.enabled && config.provider === 'redis') {
if (!config.redisUrl) {
throw new Error(
'REDIS_URL is required when CACHE_PROVIDER=redis',
);
}
return new RedisCacheStore(config.redisUrl);
}
return new MemoryCacheStore();
},
🧰 Tools
🪛 ESLint

[error] 17-19: Replace ⏎··············'REDIS_URL·is·required·when·CACHE_PROVIDER=redis',⏎············ with 'REDIS_URL·is·required·when·CACHE_PROVIDER=redis'

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/cache.module.ts` around lines 13 - 24, Update the factory in the
cache provider configuration to check config.enabled before entering the Redis
branch. When caching is disabled, return MemoryCacheStore without requiring
REDIS_URL or constructing RedisCacheStore; preserve the existing Redis
validation and selection behavior when config.enabled is true.

Comment on lines +146 to +152
private writeCacheEntry(entryKey: string, result: unknown, ttlSeconds: number) {
this.withTimeout(
this.store.set(entryKey, JSON.stringify(result), ttlSeconds),
)
.then(() => this.onOpSuccess())
.catch(() => this.onOpFailure());
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🔴 Critical | ⚡ Quick win

Guard JSON.stringify in writeCacheEntry — it can crash an otherwise-successful request.

JSON.stringify(result) at line 148 is evaluated synchronously as an argument, before withTimeout/store.set run. If it throws — for a circular reference (plausible for TypeORM entities with bidirectional relations) or a BigInt value — the exception propagates synchronously out of writeCacheEntry, which is called unawaited from getOrLoad (line 90). That rejects the promise getOrLoad returns, even though loader() already produced a valid result.

This breaks the resilience guarantee caching-strategy.md documents: "the request always succeeds; caching degrades, correctness doesn't." The read path already guards the symmetric case (JSON.parse at lines 78-84 is wrapped in try/catch); the write path should get the same treatment.

🛠️ Proposed fix
   private writeCacheEntry(entryKey: string, result: unknown, ttlSeconds: number) {
-    this.withTimeout(
-      this.store.set(entryKey, JSON.stringify(result), ttlSeconds),
-    )
-      .then(() => this.onOpSuccess())
-      .catch(() => this.onOpFailure());
+    let serialized: string;
+    try {
+      serialized = JSON.stringify(result);
+    } catch (err) {
+      this.onOpFailure();
+      this.logger.error(
+        `cache SET serialize failed key=${entryKey}: ${(err as Error).message}`,
+      );
+      return;
+    }
+    this.withTimeout(this.store.set(entryKey, serialized, ttlSeconds))
+      .then(() => this.onOpSuccess())
+      .catch(() => this.onOpFailure());
   }
🧰 Tools
🪛 ESLint

[error] 146-146: Replace entryKey:·string,·result:·unknown,·ttlSeconds:·number with ⏎····entryKey:·string,⏎····result:·unknown,⏎····ttlSeconds:·number,⏎··

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/cache.service.ts` around lines 146 - 152, Guard serialization in
writeCacheEntry by moving JSON.stringify(result) into a try/catch before
invoking withTimeout or store.set. On serialization failure, call onOpFailure
and return without throwing, preserving the successful getOrLoad result while
retaining the existing async failure handling for cache writes.

Comment on lines +32 to +37
async incr(key: string): Promise<number> {
const current = await this.get(key);
const next = (current ? parseInt(current, 10) : 0) + 1;
await this.set(key, String(next));
return next;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Fix non-atomic incr(): concurrent calls can lose increments.

incr() awaits this.get(key) then awaits this.set(key, ...). Each await yields to the event loop. Two concurrent calls to incr() on the same key can both read the same value before either writes, losing one increment.

This directly undermines CacheService.invalidate(), whose correctness depends on each INCR reliably bumping the namespace version by exactly one. A lost increment means the old-version cache entries stay reachable one version longer than intended, serving a value that should have been invalidated.

Make the read-modify-write synchronous so no await separates the read and the write.

🛠️ Proposed fix
   async incr(key: string): Promise<number> {
-    const current = await this.get(key);
-    const next = (current ? parseInt(current, 10) : 0) + 1;
-    await this.set(key, String(next));
-    return next;
+    const entry = this.entries.get(key);
+    const isValid =
+      entry !== undefined &&
+      (entry.expiresAt === null || entry.expiresAt > Date.now());
+    const current = isValid ? entry.value : null;
+    const next = (current ? parseInt(current, 10) : 0) + 1;
+    this.entries.set(key, { value: String(next), expiresAt: null });
+    return next;
   }
📝 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
async incr(key: string): Promise<number> {
const current = await this.get(key);
const next = (current ? parseInt(current, 10) : 0) + 1;
await this.set(key, String(next));
return next;
}
async incr(key: string): Promise<number> {
const entry = this.entries.get(key);
const isValid =
entry !== undefined &&
(entry.expiresAt === null || entry.expiresAt > Date.now());
const current = isValid ? entry.value : null;
const next = (current ? parseInt(current, 10) : 0) + 1;
this.entries.set(key, { value: String(next), expiresAt: null });
return next;
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/cache/memory-cache.store.ts` around lines 32 - 37, Update
MemoryCacheStore.incr so the read-modify-write operation executes synchronously
without awaits between reading and writing the key, ensuring concurrent calls
cannot lose increments. Preserve the existing numeric parsing, default value,
stored string format, and returned next value.

Comment on lines +336 to +340
await this.cacheService.invalidate(
`assessment:${tenantId}`,
'createAssessmentTracking',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# Description: Check whether CacheService caches null/negative loader results.
ast-grep outline src/cache/cache.service.ts --items all
rg -n -B3 -A15 'async getOrLoad' src/cache/cache.service.ts

Repository: tekdi/tracking-microservice

Length of output: 1509


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "== cache.service outline and relevant implementation =="
sed -n '1,180p' src/cache/cache.service.ts

echo
echo "== tracking_assessment relevant sections =="
sed -n '140,210p' src/modules/tracking_assessment/tracking_assessment.service.ts
sed -n '310,355p' src/modules/tracking_assessment/tracking_assessment.service.ts
sed -n '390,445p' src/modules/tracking_assessment/tracking_assessment.service.ts

echo
echo "== assessmentread cache usages =="
rg -n "assessmentread|assessmentTrackingId|invalidate|readCacheEntry|writeCacheEntry" src/modules/tracking_assessment src/cache

Repository: tekdi/tracking-microservice

Length of output: 20864


Invalidate the per-record cache on create.

CacheService.getOrLoad only writes when isCacheable(result) returns true, and the loader at src/modules/tracking_assessment/tracking_assessment.service.ts:97 returns false for a missing record. Since createAssessmentTracking accepts client-supplied assessmentTrackingId, creating a missing ID does not clear assessmentread:${id}; subsequent reads can return the stale false result until TTL expiration. Invalidate this namespace with the tenant namespace on create.

🛠️ Proposed fix
       await this.cacheService.invalidate(
-        `assessment:${tenantId}`,
+        [
+          `assessment:${tenantId}`,
+          `assessmentread:${result.assessmentTrackingId}`,
+        ],
         'createAssessmentTracking',
       );
📝 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
await this.cacheService.invalidate(
`assessment:${tenantId}`,
'createAssessmentTracking',
);
await this.cacheService.invalidate(
[
`assessment:${tenantId}`,
`assessmentread:${result.assessmentTrackingId}`,
],
'createAssessmentTracking',
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_assessment/tracking_assessment.service.ts` around lines
336 - 340, Update the create flow in createAssessmentTracking to invalidate the
per-record assessmentread cache key for the client-supplied assessmentTrackingId
in addition to the existing tenant-level assessment cache namespace, ensuring
newly created records do not serve a cached missing result.

Comment on lines +671 to +747
if(searchFilter?.courseId && searchFilter?.unitId && searchFilter?.contentId)
{
let output_result = [];
let contentIdArray = searchFilter?.contentId;
let contentId_text = '';
for (let i = 0; i < contentIdArray.length; i++) {
let contentId = contentIdArray[i];
if (i == 0) {
contentId_text = `${contentId_text}'${contentId}'`;
} else {
contentId_text = `${contentId_text},'${contentId}'`;
}
}
//courseId
let courseIdArray = searchFilter?.courseId;
let courseId_text = '';
for (let i = 0; i < courseIdArray.length; i++) {
let courseId = courseIdArray[i];
if (i == 0) {
courseId_text = `${courseId_text}'${courseId}'`;
} else {
courseId_text = `${courseId_text},'${courseId}'`;
}
}
//unitId
let unitIdArray = searchFilter?.unitId;
let unitId_text = '';
for (let i = 0; i < unitIdArray.length; i++) {
let unitId = unitIdArray[i];
if (i == 0) {
unitId_text = `${unitId_text}'${unitId}'`;
} else {
unitId_text = `${unitId_text},'${unitId}'`;
}
}
let userIdArray = searchFilter?.userId;
for (let i = 0; i < userIdArray.length; i++) {
let userId = userIdArray[i];
const result = await this.dataSource.query(
`WITH latest_assessment AS (
SELECT

const output_result = await this.cacheService.getOrLoad({
namespace: `assessment:${tenantId}`,
key: `status:${hashCacheParts(
userIdArray,
contentIdArray,
courseIdArray,
unitIdArray,
)}`,
ttlSeconds: ASSESSMENT_STATUS_TTL_SECONDS,
loader: async () => {
let contentId_text = '';
for (let i = 0; i < contentIdArray.length; i++) {
let contentId = contentIdArray[i];
if (i == 0) {
contentId_text = `${contentId_text}'${contentId}'`;
} else {
contentId_text = `${contentId_text},'${contentId}'`;
}
}
//courseId
let courseId_text = '';
for (let i = 0; i < courseIdArray.length; i++) {
let courseId = courseIdArray[i];
if (i == 0) {
courseId_text = `${courseId_text}'${courseId}'`;
} else {
courseId_text = `${courseId_text},'${courseId}'`;
}
}
//unitId
let unitId_text = '';
for (let i = 0; i < unitIdArray.length; i++) {
let unitId = unitIdArray[i];
if (i == 0) {
unitId_text = `${unitId_text}'${unitId}'`;
} else {
unitId_text = `${unitId_text},'${unitId}'`;
}
}
const loaded = [];
for (let i = 0; i < userIdArray.length; i++) {
let userId = userIdArray[i];
const result = await this.dataSource.query(
`WITH latest_assessment AS (
SELECT
"assessmentTrackingId",
"userId",
"courseId",
"contentId",
"attemptId",
"createdOn",
"lastAttemptedOn",
"totalMaxScore",
"totalScore",
"updatedOn",
"timeSpent",
"unitId",
"tenantId",
ROW_NUMBER() OVER (PARTITION BY "userId", "courseId", "unitId", "contentId" ORDER BY CAST("totalScore" AS INTEGER) DESC) as row_num
FROM
assessment_tracking
WHERE
"evaluatedBy" IS DISTINCT FROM 'AI' AND
"userId" = $1
AND "courseId" IN (${courseId_text})
AND "unitId" IN (${unitId_text})
AND "contentId" IN (${contentId_text})
AND "tenantId" = $2
)
SELECT

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🔴 Critical | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

node-postgres parameterized array ANY IN clause

💡 Result:

To perform a parameterized query in node-postgres that functions like an SQL IN clause, you should use the PostgreSQL = ANY($1) syntax rather than IN ($1) [1][2][3]. The standard IN operator in SQL expects a comma-separated list of values (e.g., IN (1, 2, 3)), not a single array object. If you pass a JavaScript array as a single parameter to IN ($1), PostgreSQL will treat it as a syntax error or a type mismatch [1][2]. By using = ANY($1), node-postgres correctly maps the JavaScript array to a PostgreSQL array parameter [1][4]. Example usage: const { Client } = require('pg'); const client = new Client; await client.connect; const values = ['larry', 'curly', 'moe']; // Use = ANY($1) instead of IN const query = 'SELECT * FROM stooges WHERE name = ANY($1)'; const res = await client.query(query, [values]); console.log(res.rows); If you need to handle the equivalent of a NOT IN clause, use != ALL($1) instead of NOT IN ($1) [5][6]. For scenarios where you must use the IN operator (e.g., if you are working with older database drivers or specific complex types that require manual query building), you can dynamically generate a string of placeholders [1][3]: const values = ['larry', 'curly', 'moe']; // Generate string like: $1, $2, $3 const placeholders = values.map((_, i) => $${i + 1}).join(','); const query = SELECT * FROM stooges WHERE name IN (${placeholders}); const res = await client.query(query, values); However, using = ANY($1) is generally preferred as it is cleaner, handles arbitrary array lengths naturally, and is fully compatible with parameterized queries [1][2][4].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/modules/tracking_assessment/tracking_assessment.service.ts"
if [ -f "$file" ]; then
  echo "FILE_EXISTS true"
  wc -l "$file"
  echo "--- outline relevant ---"
  ast-grep outline "$file" --match getAssessmentStatuses --view expanded || true
  echo "--- lines 640-770 ---"
  sed -n '640,770p' "$file" | nl -ba -v640
  echo "--- searchFilter validation/type references ---"
  rg -n "searchFilter|courseId|unitId|contentId|isUUID|validation" "$file"
else
  echo "FILE_MISSING"
  git ls-files | rg 'tracking_assessment.service.ts|tracking_assessment'
fi

Repository: tekdi/tracking-microservice

Length of output: 415


🏁 Script executed:

#!/bin/bash
set -euo pipefail

file="src/modules/tracking_assessment/tracking_assessment.service.ts"

echo "--- lines 640-780 ---"
awk 'NR>=640 && NR<=780 { printf "%d\t%s\n", NR, $0 }' "$file"

echo "--- searchFilter validation/type references ---"
grep -nE "searchFilter|courseId|unitId|contentId|isUUID|validation|class Search" "$file" || true

echo "--- static probe: IN interpolations and query parameter lists around matching query ---"
python3 - <<'PY'
from pathlib import Path
p=Path("src/modules/tracking_assessment/tracking_assessment.service.ts")
text=p.read_text()
for needle in [
    'contentId_text', 'courseId_text', 'unitId_text',
    'AND "courseId" IN', 'AND "unitId" IN', 'AND "contentId" IN',
    'dataSource.query',
]:
    print(f"\n--- {needle} ---")
    for i,line in enumerate(text.splitlines(),1):
        if needle in line:
            print(f"{i}: {line}")
PY

Repository: tekdi/tracking-microservice

Length of output: 11844


Remove the string-concatenated IN payloads from searchStatusAssessmentTracking.

searchFilter?.courseId, searchFilter?.unitId, and searchFilter?.contentId are read as raw array elements and interpolated into courseId_text, unitId_text, and contentId_text; these strings are then embedded at AND "courseId" IN (${courseId_text}), AND "unitId" IN (${unitId_text}), and AND "contentId" IN (${contentId_text}). A request value containing a single quote can break out of the string literal and execute arbitrary SQL.

Pass the arrays as query parameters and use = ANY($n::text[]), or generate numbered placeholders such as IN ($3, $4, $5) and pass each array element as a separate bound value.

🧰 Tools
🪛 ESLint

[error] 671-671: Replace (searchFilter?.courseId·&&·searchFilter?.unitId·&&·searchFilter?.contentId) with ·(⏎········searchFilter?.courseId·&&⏎········searchFilter?.unitId·&&⏎········searchFilter?.contentId

(prettier/prettier)


[error] 672-672: Insert

(prettier/prettier)


[error] 673-673: Insert ··

(prettier/prettier)


[error] 673-673: 'contentIdArray' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 674-674: Insert ··

(prettier/prettier)


[error] 674-674: 'courseIdArray' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 675-675: Replace ······ with ········

(prettier/prettier)


[error] 675-675: 'unitIdArray' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 676-676: Insert ··

(prettier/prettier)


[error] 676-676: 'userIdArray' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 678-678: Insert ··

(prettier/prettier)


[error] 679-679: Insert ··

(prettier/prettier)


[error] 680-680: Insert ··

(prettier/prettier)


[error] 681-681: Replace ·········· with ············

(prettier/prettier)


[error] 682-682: Insert ··

(prettier/prettier)


[error] 683-683: Insert ··

(prettier/prettier)


[error] 684-684: Insert ··

(prettier/prettier)


[error] 685-685: Insert ··

(prettier/prettier)


[error] 686-686: Insert ··

(prettier/prettier)


[error] 687-687: Insert ··

(prettier/prettier)


[error] 688-688: Insert ··

(prettier/prettier)


[error] 689-689: Replace ·········· with ············

(prettier/prettier)


[error] 690-690: Insert ··

(prettier/prettier)


[error] 690-690: 'contentId' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 691-691: Insert ··

(prettier/prettier)


[error] 692-692: Replace ·············· with ················

(prettier/prettier)


[error] 693-693: Insert ··

(prettier/prettier)


[error] 694-694: Insert ··

(prettier/prettier)


[error] 695-695: Insert ··

(prettier/prettier)


[error] 696-696: Insert ··

(prettier/prettier)


[error] 697-697: Insert ··

(prettier/prettier)


[error] 698-698: Insert ··

(prettier/prettier)


[error] 699-699: Insert ··

(prettier/prettier)


[error] 700-700: Replace ············ with ··············

(prettier/prettier)


[error] 700-700: 'courseId' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 701-701: Insert ··

(prettier/prettier)


[error] 702-702: Insert ··

(prettier/prettier)


[error] 703-703: Insert ··

(prettier/prettier)


[error] 704-704: Insert ··

(prettier/prettier)


[error] 705-705: Replace ············ with ··············

(prettier/prettier)


[error] 706-706: Insert ··

(prettier/prettier)


[error] 707-707: Insert ··

(prettier/prettier)


[error] 708-708: Insert ··

(prettier/prettier)


[error] 709-709: Insert ··

(prettier/prettier)


[error] 710-710: Insert ··

(prettier/prettier)


[error] 710-710: 'unitId' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 711-711: Insert ··

(prettier/prettier)


[error] 712-712: Insert ··

(prettier/prettier)


[error] 713-713: Insert ··

(prettier/prettier)


[error] 714-714: Insert ··

(prettier/prettier)


[error] 715-715: Insert ··

(prettier/prettier)


[error] 716-716: Insert ··

(prettier/prettier)


[error] 717-717: Insert ··

(prettier/prettier)


[error] 718-718: Insert ··

(prettier/prettier)


[error] 719-719: Insert ··

(prettier/prettier)


[error] 719-719: 'userId' is never reassigned. Use 'const' instead.

(prefer-const)


[error] 720-720: Insert ··

(prettier/prettier)


[error] 721-721: Insert ··

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_assessment/tracking_assessment.service.ts` around lines
671 - 747, Update searchStatusAssessmentTracking to remove courseId_text,
unitId_text, and contentId_text string construction; use parameterized array
predicates with = ANY(...::text[]) or individually numbered placeholders, and
pass the raw array values through dataSource.query parameters. Preserve the
existing userId and tenantId bindings while ensuring courseId, unitId, and
contentId values are never interpolated into the SQL.

Source: Linters/SAST tools

Comment on lines +310 to +314
await this.cacheService.invalidate(
[`content:${tenantId}`, `course:${tenantId}`, 'courseinprogress'],
'createContentTracking',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Invalidate contentread:${contentTrackingId} after create/update.

createContentTracking can mutate an existing row. Line 260 runs UPDATE content_tracking SET "resumeData" for an existing contentTrackingId, and lines 300-301 insert new detail rows. The invalidation list at Line 310 omits the contentread:${contentTrackingId} namespace that getContentTrackingDetails populates at Line 87. The detail read then serves a stale row for up to CONTENT_READ_TTL_SECONDS (300 seconds).

Add the entity namespace to the invalidation list.

🐛 Proposed fix
       await this.cacheService.invalidate(
-        [`content:${tenantId}`, `course:${tenantId}`, 'courseinprogress'],
+        [
+          `contentread:${contentTrackingId}`,
+          `content:${tenantId}`,
+          `course:${tenantId}`,
+          'courseinprogress',
+        ],
         'createContentTracking',
       );
📝 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
await this.cacheService.invalidate(
[`content:${tenantId}`, `course:${tenantId}`, 'courseinprogress'],
'createContentTracking',
);
await this.cacheService.invalidate(
[
`contentread:${contentTrackingId}`,
`content:${tenantId}`,
`course:${tenantId}`,
'courseinprogress',
],
'createContentTracking',
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_content/tracking_content.service.ts` around lines 310 -
314, Add the `contentread:${contentTrackingId}` entity namespace to the
invalidation keys used by `createContentTracking` after it updates or creates
tracking data, alongside the existing content and course namespaces, so
`getContentTrackingDetails` does not serve stale cached details.

Comment on lines +632 to +705
const data = await this.cacheService.getOrLoad({
namespace: `course:${tenantId}`,
key: `dashboard:${hashCacheParts(userIdArray, courseIdArray)}`,
ttlSeconds: COURSE_STATUS_TTL_SECONDS,
loader: async () => {
const certificateQuery = `
SELECT "userId", "courseId", status, "issuedOn", "createdOn", "updatedOn"
FROM user_course_certificate
WHERE "courseId" = ANY($1) AND "userId" = ANY($2::uuid[]) AND "tenantId" = $3
`;

const attemptQuery = `
SELECT "userId", "courseId", COUNT("assessmentTrackingId") as attempt_count
FROM assessment_tracking
WHERE "courseId" = ANY($1) AND "userId" = ANY($2::uuid[]) AND "tenantId" = $3
GROUP BY "userId", "courseId"
`;

const [certificateResults, attemptResults] = await Promise.all([
this.dataSource.query(certificateQuery, [courseIdArray, userIdArray, tenantId]),
this.dataSource.query(attemptQuery, [courseIdArray, userIdArray, tenantId]),
]);

const statusMap = new Map<
string,
{ status: string; issuedOn: Date | null; createdOn: Date | null; updatedOn: Date | null }
>();
for (const row of certificateResults) {
statusMap.set(`${row.courseId}_${row.userId}`, {
status: row.status,
issuedOn: row.issuedOn,
createdOn: row.createdOn,
updatedOn: row.updatedOn,
});
}

const data = courseIdArray.map((courseId) => {
const userStatusMap: Record<
string,
{
status: string;
highestAttempt: number;
issuedOn: Date | null;
createdOn: Date | null;
updatedOn: Date | null;
const attemptMap = new Map<string, number>();
for (const row of attemptResults) {
attemptMap.set(`${row.courseId}_${row.userId}`, parseInt(row.attempt_count) || 0);
}
> = {};

for (const userId of userIdArray) {
const key = `${courseId}_${userId}`;
const certificate = statusMap.get(key);
const rawStatus = certificate?.status;
const status =
!rawStatus || rawStatus.toLowerCase() === 'enrolled'
? 'not_started'
: rawStatus;

userStatusMap[userId] = {
status,
highestAttempt: attemptMap.get(key) || 0,
issuedOn: certificate?.issuedOn ?? null,
createdOn: certificate?.createdOn ?? null,
updatedOn: certificate?.updatedOn ?? null,
};
}

return { [courseId]: userStatusMap };
return courseIdArray.map((courseId) => {
const userStatusMap: Record<
string,
{
status: string;
highestAttempt: number;
issuedOn: Date | null;
createdOn: Date | null;
updatedOn: Date | null;
}
> = {};

for (const userId of userIdArray) {
const key = `${courseId}_${userId}`;
const certificate = statusMap.get(key);
const rawStatus = certificate?.status;
const status =
!rawStatus || rawStatus.toLowerCase() === 'enrolled'
? 'not_started'
: rawStatus;

userStatusMap[userId] = {
status,
highestAttempt: attemptMap.get(key) || 0,
issuedOn: certificate?.issuedOn ?? null,
createdOn: certificate?.createdOn ?? null,
updatedOn: certificate?.updatedOn ?? null,
};
}

return { [courseId]: userStatusMap };
});
},

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

🧩 Analysis chain

🏁 Script executed:

#!/bin/bash
# List every cacheService.invalidate call and its namespaces across modules.
rg -nP -C4 'cacheService\.invalidate\(' src

Repository: tekdi/tracking-microservice

Length of output: 7587


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Files:"
git ls-files | rg 'src/modules/(tracking_assessment|tracking_content|certificate|user_certificate)/.*(service|module|controller|dto|*.ts)$|src/modules/.*service\.ts$|src/cache' | sed 's/^/  /' | head -200

echo
echo "tracking_assessment invalidate call sites:"
sed -n '300,360p' src/modules/tracking_assessment/tracking_assessment.service.ts
sed -n '430,470p' src/modules/tracking_assessment/tracking_assessment.service.ts
sed -n '1200,1250p' src/modules/tracking_assessment/tracking_assessment.service.ts

echo
echo "tracking_assessment repository/call graph identifiers:"
rg -n "assessment_tracking|createAssessmentTracking|updateAssessmentTracking|deleteAssessmentTracking|invalidate|assessmentTracking" src/modules/tracking_assessment/tracking_assessment.service.ts src/modules/tracking_assessment | head -200

echo
echo "tracking_content course invalidation context:"
sed -n '280,320p' src/modules/tracking_content/tracking_content.service.ts
sed -n '1280,1310p' src/modules/tracking_content/tracking_content.service.ts

Repository: tekdi/tracking-microservice

Length of output: 528


🏁 Script executed:

#!/bin/bash
set -euo pipefail
echo "Assess whether invalidate calls can propagate across cacheService methods:"
rg -n "class .*Cache|invalidat|invalidate\(|new Map|cache.*set|getOrLoad|cacheService" src -g '*.ts' | head -300

Repository: tekdi/tracking-microservice

Length of output: 7833


🏁 Script executed:

#!/bin/bash
set -euo pipefail

echo "Cache service implementation:"
sed -n '1,190p' src/cache/cache.service.ts

echo
echo "Redis cache store:"
sed -n '1,220p' src/cache/redis-cache.store.ts

echo
echo "Assessment write/delete locations and invalidate context:"
grep -n "async createAssessmentTracking\|async updateAssessmentTracking\|async deleteAssessmentTracking\|cacheService\.invalidate\|assessment_tracking\|repository\." src/modules/tracking_assessmetn/tracking_assessment.service.ts 2>/dev/null || true
grep -n "async createAssessmentTracking\|async updateAssessmentTracking\|async deleteAssessmentTracking\|cacheService\.invalidate\|assessment_tracking\|repository\." src/modules/tracking_assessment/tracking_assessment.service.ts

Repository: tekdi/tracking-microservice

Length of output: 8660


Invalidate course:${tenantId} on assessment tracking writes.

createAssessmentTracking() and deleteAssessmentTracking() only bump assessment:${tenantId}, but getOrLoad() under course:${tenantId} caches attempt_count from assessment_tracking; dashboard counts can stay stale after create or delete. Add course:${tenantId} to those invalidations and any update path that changes attempted assessments for a course/user.

🧰 Tools
🪛 ESLint

[error] 651-651: Replace courseIdArray,·userIdArray,·tenantId with ⏎················courseIdArray,⏎················userIdArray,⏎················tenantId,⏎··············

(prettier/prettier)


[error] 652-652: Replace courseIdArray,·userIdArray,·tenantId with ⏎················courseIdArray,⏎················userIdArray,⏎················tenantId,⏎··············

(prettier/prettier)


[error] 657-657: Replace ·status:·string;·issuedOn:·Date·|·null;·createdOn:·Date·|·null;·updatedOn:·Date·|·null with ⏎················status:·string;⏎················issuedOn:·Date·|·null;⏎················createdOn:·Date·|·null;⏎················updatedOn:·Date·|·null;⏎·············

(prettier/prettier)


[error] 670-670: Replace ``${row.courseId}${row.userId},·parseInt(row.attempt_count)·||·0 with `⏎················`${row.courseId}${row.userId}`,⏎················parseInt(row.attempt_count)·||·0,⏎··············`

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_content/tracking_content.service.ts` around lines 632 -
705, The assessment tracking write paths must also invalidate the course
dashboard cache because getOrLoad caches attempt_count under course:${tenantId}.
Update createAssessmentTracking, deleteAssessmentTracking, and any
assessment-tracking update path that changes a course/user’s attempts to
invalidate course:${tenantId} alongside assessment:${tenantId}, reusing the
existing tenant-scoped cache invalidation mechanism.

Comment on lines +651 to +670
this.dataSource.query(certificateQuery, [courseIdArray, userIdArray, tenantId]),
this.dataSource.query(attemptQuery, [courseIdArray, userIdArray, tenantId]),
]);

const statusMap = new Map<
string,
{ status: string; issuedOn: Date | null; createdOn: Date | null; updatedOn: Date | null }
>();
for (const row of certificateResults) {
statusMap.set(`${row.courseId}_${row.userId}`, {
status: row.status,
issuedOn: row.issuedOn,
createdOn: row.createdOn,
updatedOn: row.updatedOn,
});
}

const data = courseIdArray.map((courseId) => {
const userStatusMap: Record<
string,
{
status: string;
highestAttempt: number;
issuedOn: Date | null;
createdOn: Date | null;
updatedOn: Date | null;
const attemptMap = new Map<string, number>();
for (const row of attemptResults) {
attemptMap.set(`${row.courseId}_${row.userId}`, parseInt(row.attempt_count) || 0);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win

Fix the lint errors reported on the new dashboard loader.

ESLint reports prettier/prettier errors on lines 651, 652, 657, and 670. These lines are new. Run the formatter to keep the lint job green.

🧰 Tools
🪛 ESLint

[error] 651-651: Replace courseIdArray,·userIdArray,·tenantId with ⏎················courseIdArray,⏎················userIdArray,⏎················tenantId,⏎··············

(prettier/prettier)


[error] 652-652: Replace courseIdArray,·userIdArray,·tenantId with ⏎················courseIdArray,⏎················userIdArray,⏎················tenantId,⏎··············

(prettier/prettier)


[error] 657-657: Replace ·status:·string;·issuedOn:·Date·|·null;·createdOn:·Date·|·null;·updatedOn:·Date·|·null with ⏎················status:·string;⏎················issuedOn:·Date·|·null;⏎················createdOn:·Date·|·null;⏎················updatedOn:·Date·|·null;⏎·············

(prettier/prettier)


[error] 670-670: Replace ``${row.courseId}${row.userId},·parseInt(row.attempt_count)·||·0 with `⏎················`${row.courseId}${row.userId}`,⏎················parseInt(row.attempt_count)·||·0,⏎··············`

(prettier/prettier)

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/tracking_content/tracking_content.service.ts` around lines 651 -
670, Run Prettier on the new dashboard loader code around the
certificateResults, statusMap, and attemptMap processing in the tracking
service, correcting the reported formatting violations on the query and map
lines while preserving the existing logic.

Source: Linters/SAST tools

Comment on lines +245 to 262
Object.keys(filters).forEach((key) => {
const value = filters[key];

if (Array.isArray(value) && value.length > 0) {
// Array filter - use IN clause
queryBuilder.andWhere(
`UserCourseCertificate.${key} IN (:...${key})`,
{
[key]: value,
},
);
} else if (value) {
// Single value filter - use equality
queryBuilder.andWhere(`UserCourseCertificate.${key} = :${key}`, {
[key]: value,
if (Array.isArray(value) && value.length > 0) {
// Array filter - use IN clause
queryBuilder.andWhere(
`UserCourseCertificate.${key} IN (:...${key})`,
{
[key]: value,
},
);
} else if (value) {
// Single value filter - use equality
queryBuilder.andWhere(`UserCourseCertificate.${key} = :${key}`, {
[key]: value,
});
}
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔒 Security & Privacy | 🟠 Major | ⚡ Quick win

Restrict filters keys to an allowlist.

Lines 250-260 interpolate key into the SQL fragment and into the parameter name. filters comes from the request body, so a caller controls the fragment text. TypeORM passes the fragment to the query planner after property resolution, so an unexpected key can produce an error or an unintended condition. The cache layer also stores the resulting rows for 120 seconds under a hashed key, which extends the effect of any accepted malformed filter.

Validate key against the allowed column names of UserCourseCertificate before you add the condition.

🔒 Proposed fix
+          const allowedFilterKeys = [
+            'userId',
+            'courseId',
+            'status',
+            'certificateId',
+            'usercertificateId',
+          ];
           Object.keys(filters).forEach((key) => {
+            if (!allowedFilterKeys.includes(key)) {
+              throw new Error(`Invalid filter key: ${key}`);
+            }
             const value = filters[key];
📝 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
Object.keys(filters).forEach((key) => {
const value = filters[key];
if (Array.isArray(value) && value.length > 0) {
// Array filter - use IN clause
queryBuilder.andWhere(
`UserCourseCertificate.${key} IN (:...${key})`,
{
[key]: value,
},
);
} else if (value) {
// Single value filter - use equality
queryBuilder.andWhere(`UserCourseCertificate.${key} = :${key}`, {
[key]: value,
if (Array.isArray(value) && value.length > 0) {
// Array filter - use IN clause
queryBuilder.andWhere(
`UserCourseCertificate.${key} IN (:...${key})`,
{
[key]: value,
},
);
} else if (value) {
// Single value filter - use equality
queryBuilder.andWhere(`UserCourseCertificate.${key} = :${key}`, {
[key]: value,
});
}
});
const allowedFilterKeys = [
'userId',
'courseId',
'status',
'certificateId',
'usercertificateId',
];
Object.keys(filters).forEach((key) => {
if (!allowedFilterKeys.includes(key)) {
throw new Error(`Invalid filter key: ${key}`);
}
const value = filters[key];
if (Array.isArray(value) && value.length > 0) {
// Array filter - use IN clause
queryBuilder.andWhere(
`UserCourseCertificate.${key} IN (:...${key})`,
{
[key]: value,
},
);
} else if (value) {
// Single value filter - use equality
queryBuilder.andWhere(`UserCourseCertificate.${key} = :${key}`, {
[key]: value,
});
}
});
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/user_certificate/user_certificate.service`..ts around lines 245 -
262, Restrict the filter iteration in the UserCourseCertificate query-building
flow to an explicit allowlist of valid UserCourseCertificate column names before
interpolating key into SQL or parameter names. Skip or reject any unapproved
key, while preserving the existing array IN and scalar equality handling for
allowed keys.

Comment on lines +334 to +338
await this.cacheService.invalidate(
`usercert:${tenantId}`,
'importUserDataForCertificate',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🗄️ Data Integrity & Integration | 🟠 Major | ⚡ Quick win

Invalidate the namespace of the tenant that owns the saved record.

Line 318 looks up the record with createUserCertificateDto.tenantId, and line 330 saves the DTO. The invalidation at Line 335 uses request.tenantId. If the two values differ, this call bumps the wrong namespace and fetchUserStatusForCourse and searchUsersCourses keep serving stale data for the record's real tenant.

enrollUserForCourse also invalidates course:${tenantId}. Import writes the same table, so include that namespace for consistency.

🐛 Proposed fix
+      const recordTenantId = result.tenantId ?? tenantId;
       await this.cacheService.invalidate(
-        `usercert:${tenantId}`,
+        [`usercert:${recordTenantId}`, `course:${recordTenantId}`],
         'importUserDataForCertificate',
       );
📝 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
await this.cacheService.invalidate(
`usercert:${tenantId}`,
'importUserDataForCertificate',
);
const recordTenantId = result.tenantId ?? tenantId;
await this.cacheService.invalidate(
[`usercert:${recordTenantId}`, `course:${recordTenantId}`],
'importUserDataForCertificate',
);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/modules/user_certificate/user_certificate.service`..ts around lines 334 -
338, Update the cache invalidation in the import flow around the user
certificate record lookup and save to use the saved record’s tenantId (from
createUserCertificateDto) rather than request.tenantId. Also invalidate the
corresponding course:${tenantId} namespace, matching enrollUserForCourse, so
both usercert and course caches are cleared for the record’s owning tenant.

@sonarqubecloud

sonarqubecloud Bot commented Aug 4, 2026

Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
D Security Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE


export function hashCacheParts(...parts: unknown[]): string {
const normalized = JSON.stringify(parts);
return createHash('sha1').update(normalized).digest('hex');
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants