From 4195243a330f148c03f1517e3656b8cf48a11a95 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 23 Mar 2026 19:28:02 +0530 Subject: [PATCH 01/21] fix: added user metrics [SPRW-3110] --- .../common/enum/database.collection.enum.ts | 1 + .../common/models/user-metrics.model.ts | 96 ++++ .../repositories/userMetrics.repository.ts | 414 ++++++++++++++++++ src/modules/workspace/workspace.module.ts | 3 + 4 files changed, 514 insertions(+) create mode 100644 src/modules/common/models/user-metrics.model.ts create mode 100644 src/modules/workspace/repositories/userMetrics.repository.ts diff --git a/src/modules/common/enum/database.collection.enum.ts b/src/modules/common/enum/database.collection.enum.ts index d4ae22461..754b86183 100644 --- a/src/modules/common/enum/database.collection.enum.ts +++ b/src/modules/common/enum/database.collection.enum.ts @@ -23,4 +23,5 @@ export enum Collections { PROMOCODES = "promocodes", SUPERADMINS = "superadmins", NOTIFICATIONS = "notifications", + USER_METRICS = "user_metrics", } diff --git a/src/modules/common/models/user-metrics.model.ts b/src/modules/common/models/user-metrics.model.ts new file mode 100644 index 000000000..1d524fe48 --- /dev/null +++ b/src/modules/common/models/user-metrics.model.ts @@ -0,0 +1,96 @@ +import { + IsDate, + IsNotEmpty, + IsNumber, + IsOptional, + IsString, +} from "class-validator"; + +/** + * UserMetrics model for precomputed weekly digest metrics. + * Designed for efficient upserts and bulk reads without aggregation. + */ +export class UserMetrics { + /** + * The user ID this metric belongs to. Indexed for fast lookups. + */ + @IsString() + @IsNotEmpty() + userId: string; + + /** + * The start of the week (Monday 00:00:00 UTC) this metric covers. + * Combined with userId for compound index. + */ + @IsDate() + @IsNotEmpty() + weekStart: Date; + + /** + * Total number of executions (updates/activities) by the user this week. + */ + @IsNumber() + @IsOptional() + totalExecutions?: number; + + /** + * Number of APIs created by the user this week. + */ + @IsNumber() + @IsOptional() + apisCreated?: number; + + /** + * Number of collections the user has access to. + */ + @IsNumber() + @IsOptional() + collectionsCount?: number; + + /** + * Number of active workspaces the user participated in this week. + */ + @IsNumber() + @IsOptional() + activeWorkspaces?: number; + + /** + * Number of testflows executed by the user this week. + */ + @IsNumber() + @IsOptional() + testflowsExecuted?: number; + + /** + * Timestamp when this metric was last updated. + */ + @IsDate() + @IsOptional() + updatedAt?: Date; +} + +/** + * Payload for incrementing metrics. All fields are optional + * since we use $inc for partial updates. + */ +export interface IncrementMetricsPayload { + totalExecutions?: number; + apisCreated?: number; + collectionsCount?: number; + activeWorkspaces?: number; + testflowsExecuted?: number; +} + +/** + * Metrics data returned from the repository. + */ +export interface UserMetricsData { + userId: string; + weekStart: Date; + totalExecutions: number; + apisCreated: number; + collectionsCount: number; + activeWorkspaces: number; + testflowsExecuted: number; + updatedAt: Date; +} diff --git a/src/modules/workspace/repositories/userMetrics.repository.ts b/src/modules/workspace/repositories/userMetrics.repository.ts new file mode 100644 index 000000000..6c0912bb6 --- /dev/null +++ b/src/modules/workspace/repositories/userMetrics.repository.ts @@ -0,0 +1,414 @@ +import { Inject, Injectable, Logger, OnModuleInit } from "@nestjs/common"; +import { Db, WithId, BulkWriteResult } from "mongodb"; + +// ---- Enum +import { Collections } from "@src/modules/common/enum/database.collection.enum"; + +// ---- Model +import { + UserMetrics, + IncrementMetricsPayload, + UserMetricsData, +} from "@src/modules/common/models/user-metrics.model"; + +/** + * UserMetrics Repository + * Handles precomputed weekly digest metrics for efficient email generation. + * Designed for millions of users with bulk operations and proper indexing. + */ +@Injectable() +export class UserMetricsRepository implements OnModuleInit { + private readonly logger = new Logger(UserMetricsRepository.name); + + constructor(@Inject("DATABASE_CONNECTION") private db: Db) {} + + /** + * Initialize indexes on module startup. + * Creates compound index on { userId: 1, weekStart: 1 } for efficient lookups. + */ + async onModuleInit(): Promise { + try { + const collection = this.db.collection(Collections.USER_METRICS); + + // Create compound index for userId + weekStart (unique per user per week) + await collection.createIndex( + { userId: 1, weekStart: 1 }, + { unique: true, background: true }, + ); + + await collection.createIndex( + { weekStart: 1, userId: 1 }, + { background: true }, + ); + + // Create index on weekStart for cleanup/maintenance queries + await collection.createIndex({ weekStart: 1 }, { background: true }); + + // Create index on updatedAt for maintenance queries + await collection.createIndex({ updatedAt: 1 }, { background: true }); + + this.logger.log("UserMetrics indexes created successfully"); + } catch (error) { + this.logger.error("Failed to create UserMetrics indexes", error); + } + } + + /** + * Get the start of the current week (Monday 00:00:00 UTC). + * Used to normalize weekStart for consistent grouping. + */ + getWeekStart(date: Date = new Date()): Date { + const d = new Date(date); + const day = d.getUTCDay(); + // Adjust to Monday (day 1), if Sunday (day 0), go back 6 days + const diff = day === 0 ? -6 : 1 - day; + d.setUTCDate(d.getUTCDate() + diff); + d.setUTCHours(0, 0, 0, 0); + return d; + } + + /** + * Increment metrics for a single user using upsert. + * Uses $inc for atomic increments, creating the document if it doesn't exist. + * + * @param userId The user ID to update metrics for + * @param weekStart The start of the week for this metric + * @param payload Partial metrics to increment + */ + async incrementMetrics( + userId: string, + weekStart: Date, + payload: IncrementMetricsPayload, + ): Promise { + const incPayload: Record = {}; + + if (payload.totalExecutions !== undefined) { + incPayload.totalExecutions = payload.totalExecutions; + } + if (payload.apisCreated !== undefined) { + incPayload.apisCreated = payload.apisCreated; + } + if (payload.collectionsCount !== undefined) { + incPayload.collectionsCount = payload.collectionsCount; + } + if (payload.activeWorkspaces !== undefined) { + incPayload.activeWorkspaces = payload.activeWorkspaces; + } + if (payload.testflowsExecuted !== undefined) { + incPayload.testflowsExecuted = payload.testflowsExecuted; + } + + // Skip if no metrics to increment + if (Object.keys(incPayload).length === 0) { + return; + } + + await this.db.collection(Collections.USER_METRICS).updateOne( + { userId, weekStart }, + { + $inc: incPayload, + $set: { updatedAt: new Date() }, + $setOnInsert: { + userId, + weekStart, + totalExecutions: 0, + apisCreated: 0, + collectionsCount: 0, + activeWorkspaces: 0, + testflowsExecuted: 0, + }, + }, + { upsert: true }, + ); + } + + /** + * Bulk increment metrics for multiple users. + * Uses bulkWrite for efficient batch operations. + * Merges operations for the same userId to reduce DB writes. + * + * @param operations Array of { userId, payload } to increment + * @param weekStart The start of the week for these metrics + */ + async bulkIncrementMetrics( + operations: Array<{ userId: string; payload: IncrementMetricsPayload }>, + weekStart: Date, + ): Promise { + if (operations.length === 0) { + return { + ok: 1, + insertedCount: 0, + matchedCount: 0, + modifiedCount: 0, + deletedCount: 0, + upsertedCount: 0, + insertedIds: {}, + upsertedIds: {}, + } as BulkWriteResult; + } + + // Merge operations by userId to reduce redundant DB writes + const mergedOps = this.mergeOperationsByUserId(operations); + this.logger.log( + `UserMetrics bulk merge: ${operations.length} → ${mergedOps.size}`, + ); + + const bulkOps = Array.from(mergedOps.entries()).map(([userId, payload]) => { + const incPayload: Record = {}; + + if (payload.totalExecutions !== undefined) { + incPayload.totalExecutions = payload.totalExecutions; + } + if (payload.apisCreated !== undefined) { + incPayload.apisCreated = payload.apisCreated; + } + if (payload.collectionsCount !== undefined) { + incPayload.collectionsCount = payload.collectionsCount; + } + if (payload.activeWorkspaces !== undefined) { + incPayload.activeWorkspaces = payload.activeWorkspaces; + } + if (payload.testflowsExecuted !== undefined) { + incPayload.testflowsExecuted = payload.testflowsExecuted; + } + + return { + updateOne: { + filter: { userId, weekStart }, + update: { + $inc: incPayload, + $set: { updatedAt: new Date() }, + $setOnInsert: { + userId, + weekStart, + totalExecutions: 0, + apisCreated: 0, + collectionsCount: 0, + activeWorkspaces: 0, + testflowsExecuted: 0, + }, + }, + upsert: true, + }, + }; + }); + + return await this.db + .collection(Collections.USER_METRICS) + .bulkWrite(bulkOps, { ordered: false }); + } + + /** + * Merge multiple operations for the same userId by summing their payloads. + * Reduces redundant DB operations for high-frequency events. + * + * @param operations Array of operations to merge + * @returns Map of userId to merged IncrementMetricsPayload + */ + private mergeOperationsByUserId( + operations: Array<{ userId: string; payload: IncrementMetricsPayload }>, + ): Map { + const merged = new Map(); + + for (const { userId, payload } of operations) { + const existing = merged.get(userId); + + if (!existing) { + // Clone the payload to avoid mutating the original + merged.set(userId, { ...payload }); + } else { + // Sum all numeric fields + if (payload.totalExecutions !== undefined) { + existing.totalExecutions = + (existing.totalExecutions || 0) + payload.totalExecutions; + } + if (payload.apisCreated !== undefined) { + existing.apisCreated = + (existing.apisCreated || 0) + payload.apisCreated; + } + if (payload.collectionsCount !== undefined) { + existing.collectionsCount = + (existing.collectionsCount || 0) + payload.collectionsCount; + } + if (payload.activeWorkspaces !== undefined) { + existing.activeWorkspaces = + (existing.activeWorkspaces || 0) + payload.activeWorkspaces; + } + if (payload.testflowsExecuted !== undefined) { + existing.testflowsExecuted = + (existing.testflowsExecuted || 0) + payload.testflowsExecuted; + } + } + } + + return merged; + } + + /** + * Get metrics for multiple users for a specific week. + * Returns a Map for O(1) lookup by userId. + * + * @param userIds Array of user IDs to fetch metrics for + * @param weekStart The start of the week to fetch metrics for + * @returns Map of userId to UserMetricsData + */ + async getMetricsForUsers( + userIds: string[], + weekStart: Date, + ): Promise> { + if (userIds.length === 0) { + return new Map(); + } + + const results = await this.db + .collection(Collections.USER_METRICS) + .find( + { + userId: { $in: userIds }, + weekStart, + }, + { + projection: { + userId: 1, + weekStart: 1, + totalExecutions: 1, + apisCreated: 1, + collectionsCount: 1, + activeWorkspaces: 1, + testflowsExecuted: 1, + updatedAt: 1, + }, + }, + ) + .toArray(); + + const metricsMap = new Map(); + + for (const result of results) { + metricsMap.set(result.userId, { + userId: result.userId, + weekStart: result.weekStart, + totalExecutions: result.totalExecutions || 0, + apisCreated: result.apisCreated || 0, + collectionsCount: result.collectionsCount || 0, + activeWorkspaces: result.activeWorkspaces || 0, + testflowsExecuted: result.testflowsExecuted || 0, + updatedAt: result.updatedAt || new Date(), + }); + } + + return metricsMap; + } + + /** + * Get metrics for a single user for a specific week. + * + * @param userId The user ID to fetch metrics for + * @param weekStart The start of the week to fetch metrics for + * @returns UserMetricsData or null if not found + */ + async getMetricsForUser( + userId: string, + weekStart: Date, + ): Promise { + const result = await this.db + .collection(Collections.USER_METRICS) + .findOne( + { userId, weekStart }, + { + projection: { + userId: 1, + weekStart: 1, + totalExecutions: 1, + apisCreated: 1, + collectionsCount: 1, + activeWorkspaces: 1, + testflowsExecuted: 1, + updatedAt: 1, + }, + }, + ); + + if (!result) { + return null; + } + + return { + userId: result.userId, + weekStart: result.weekStart, + totalExecutions: result.totalExecutions || 0, + apisCreated: result.apisCreated || 0, + collectionsCount: result.collectionsCount || 0, + activeWorkspaces: result.activeWorkspaces || 0, + testflowsExecuted: result.testflowsExecuted || 0, + updatedAt: result.updatedAt || new Date(), + }; + } + + /** + * Set absolute metric values for a user (not increment). + * Useful for recalculating/resetting metrics. + * + * @param userId The user ID to set metrics for + * @param weekStart The start of the week for this metric + * @param metrics The metrics to set + */ + async setMetrics( + userId: string, + weekStart: Date, + metrics: Partial, + ): Promise { + const setPayload: Record = { + updatedAt: new Date(), + }; + + if (metrics.totalExecutions !== undefined) { + setPayload.totalExecutions = metrics.totalExecutions; + } + if (metrics.apisCreated !== undefined) { + setPayload.apisCreated = metrics.apisCreated; + } + if (metrics.collectionsCount !== undefined) { + setPayload.collectionsCount = metrics.collectionsCount; + } + if (metrics.activeWorkspaces !== undefined) { + setPayload.activeWorkspaces = metrics.activeWorkspaces; + } + if (metrics.testflowsExecuted !== undefined) { + setPayload.testflowsExecuted = metrics.testflowsExecuted; + } + + await this.db.collection(Collections.USER_METRICS).updateOne( + { userId, weekStart }, + { + $set: setPayload, + $setOnInsert: { + userId, + weekStart, + totalExecutions: 0, + apisCreated: 0, + collectionsCount: 0, + activeWorkspaces: 0, + testflowsExecuted: 0, + }, + }, + { upsert: true }, + ); + } + + /** + * Delete old metrics to prevent unbounded growth. + * Should be called periodically (e.g., weekly cleanup job). + * + * @param olderThan Delete metrics older than this date + * @returns Number of documents deleted + */ + async cleanupOldMetrics(olderThan: Date): Promise { + const result = await this.db + .collection(Collections.USER_METRICS) + .deleteMany({ weekStart: { $lt: olderThan } }); + + this.logger.log(`Cleaned up ${result.deletedCount} old user metrics`); + return result.deletedCount; + } +} diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts index 69c781444..903b519f8 100644 --- a/src/modules/workspace/workspace.module.ts +++ b/src/modules/workspace/workspace.module.ts @@ -26,6 +26,7 @@ import { ChatbotStatsRepository } from "./repositories/chatbot-stats.repositoy"; import { TestflowRepository } from "./repositories/testflow.repository"; import { SalesEmailRepository } from "./repositories/sales-email.repository"; import { PricingRepository } from "./repositories/pricing.repository"; +import { UserMetricsRepository } from "./repositories/userMetrics.repository"; // ---- Module import { IdentityModule } from "../identity/identity.module"; @@ -151,6 +152,7 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; AiConsumptionScheduler, WeeklyDigestScheduler, WeeklyDigestService, + UserMetricsRepository, ], exports: [ CollectionService, @@ -179,6 +181,7 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; SalesEmailRepository, PricingService, PricingRepository, + UserMetricsRepository, ], controllers: [ WorkSpaceController, From 440fd9fda91964bf3828d53eb5a427b2c10ce48c Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 23 Mar 2026 19:58:31 +0530 Subject: [PATCH 02/21] fix: integrate user metrics with event-driven updates [SPRW-3110] --- .../services/collection-request.service.ts | 198 ++++++++++++------ .../workspace/services/collection.service.ts | 11 +- .../services/testflow-run.service.ts | 19 ++ .../workspace/services/updates.service.ts | 11 +- .../workspace/services/userMetrics.service.ts | 192 +++++++++++++++++ src/modules/workspace/workspace.module.ts | 2 + 6 files changed, 364 insertions(+), 69 deletions(-) create mode 100644 src/modules/workspace/services/userMetrics.service.ts diff --git a/src/modules/workspace/services/collection-request.service.ts b/src/modules/workspace/services/collection-request.service.ts index bb1ec278e..083429f9d 100644 --- a/src/modules/workspace/services/collection-request.service.ts +++ b/src/modules/workspace/services/collection-request.service.ts @@ -41,6 +41,8 @@ import { DecodedUserObject } from "@src/types/fastify"; import { EncryptionService } from "@src/modules/common/services/encryption.service"; import { Workspace } from "@src/modules/common/models/workspace.model"; import { VariableDto } from "@src/modules/common/models/environment.model"; +import { UserMetricsService } from "./userMetrics.service"; + @Injectable() export class CollectionRequestService { constructor( @@ -50,6 +52,7 @@ export class CollectionRequestService { private readonly branchRepository: BranchRepository, private readonly producerService: ProducerService, private readonly encryptionService: EncryptionService, + private readonly userMetricsService: UserMetricsService, ) {} async addFolder( @@ -378,6 +381,10 @@ export class CollectionRequestService { workspaceId: request.workspaceId, }), }); + + // Track API creation (fire-and-forget) + this.userMetricsService.onApiCreated(user._id.toString()); + return requestObj; } else { requestObj.items = [ @@ -426,6 +433,10 @@ export class CollectionRequestService { workspaceId: request.workspaceId, }), }); + + // Track API creation (fire-and-forget) + this.userMetricsService.onApiCreated(user._id.toString()); + return requestObj.items[0]; } } @@ -1620,7 +1631,6 @@ export class CollectionRequestService { }; let updateMessage = ``; if (aiRequest.items.type === ItemTypeEnum.AI_REQUEST) { - let encryptedAuthValue: string | undefined; if (aiRequest.items.aiRequest?.auth?.apiKey?.authValue) { encryptedAuthValue = this.encryptionService.encrypt( @@ -1636,8 +1646,7 @@ export class CollectionRequestService { }, }, }; - } - else { + } else { aiRequestObj.aiRequest = aiRequest.items.aiRequest; } @@ -1680,12 +1689,9 @@ export class CollectionRequestService { }, }, }; - } - else { + } else { return aiRequestObj; } - - } else { if (aiRequest.items.items.aiRequest?.auth?.apiKey?.authValue) { const encryptedAuthValue = this.encryptionService.encrypt( @@ -1714,8 +1720,7 @@ export class CollectionRequestService { updatedAt: new Date(), }, ]; - } - else { + } else { aiRequestObj.items = [ { id: uuidv4(), @@ -1765,15 +1770,15 @@ export class CollectionRequestService { apiKey: { ...aiRequestObj.items[0].aiRequest.auth.apiKey, authValue: this.encryptionService.decrypt( - aiRequestObj.items[0].aiRequest.auth.apiKey.authValue as string, + aiRequestObj.items[0].aiRequest.auth.apiKey + .authValue as string, ), }, }, }, }; return decryptedItem; - } - else { + } else { return aiRequestObj.items[0]; } } @@ -1816,8 +1821,7 @@ export class CollectionRequestService { }, }; } - } - else { + } else { if (aiRequest.items.items.aiRequest?.auth?.apiKey?.authValue) { const encryptedAuthValue = this.encryptionService.encrypt( aiRequest.items.items.aiRequest.auth.apiKey.authValue as string, @@ -1847,12 +1851,12 @@ export class CollectionRequestService { // Decrypt authValue in flat structure if (collection?.aiRequest?.auth?.apiKey?.authValue) { - collection.aiRequest.auth.apiKey.authValue = this.encryptionService.decrypt( + collection.aiRequest.auth.apiKey.authValue = + this.encryptionService.decrypt( String(collection.aiRequest.auth.apiKey.authValue), ); } - const currentWorkspaceObject = new ObjectId(aiRequest.workspaceId); const updateWorkspaceData: Partial = { updatedAt: new Date(), @@ -2183,7 +2187,9 @@ export class CollectionRequestService { } // Extract data from collection - const { urls, bodies, queryParams, headers } = this.extractFromItems(collection.items); + const { urls, bodies, queryParams, headers } = this.extractFromItems( + collection.items, + ); // Generate variables for each type const urlVariables = Object.entries(this.generateUrlVariables(urls)).map( @@ -2234,10 +2240,12 @@ export class CollectionRequestService { */ public clean(arr: any[] = []): any[] { return Array.isArray(arr) - ? arr.filter(entry => { + ? arr.filter((entry) => { const key = entry?.key?.trim().toLowerCase(); const value = entry?.value?.trim(); - return key && value && key !== 'user-agent' && key !== 'accept-encoding'; + return ( + key && value && key !== "user-agent" && key !== "accept-encoding" + ); }) : []; } @@ -2293,7 +2301,7 @@ export class CollectionRequestService { const urlencoded = this.clean(req.body?.urlencoded); const formdataText = this.clean(req.body?.formdata?.text); const formdataFile = this.clean(req.body?.formdata?.file); - const raw = req.body?.raw || ''; + const raw = req.body?.raw || ""; const body: any = { raw }; @@ -2305,9 +2313,10 @@ export class CollectionRequestService { } const hasBodyContent = - raw.trim() !== '' || - (body.urlencoded?.length > 0) || - (body.formdata?.text?.length > 0 || body.formdata?.file?.length > 0); + raw.trim() !== "" || + body.urlencoded?.length > 0 || + body.formdata?.text?.length > 0 || + body.formdata?.file?.length > 0; if (hasBodyContent) { bodies.push(body); @@ -2348,7 +2357,8 @@ export class CollectionRequestService { if (Object.keys(socketBody).length > 0) bodies.push(socketBody); const cleanedSocketQuery = this.clean(req.queryParams); - if (cleanedSocketQuery.length > 0) queryParams.push(cleanedSocketQuery); + if (cleanedSocketQuery.length > 0) + queryParams.push(cleanedSocketQuery); break; case ItemTypeEnum.GRAPHQL: @@ -2414,15 +2424,18 @@ export class CollectionRequestService { const existingVariablePattern = /\{\{?[^}]+\}?\}/g; const preservedVariables = new Set(); - urls.forEach(url => { + urls.forEach((url) => { const matches = url.match(existingVariablePattern); if (matches) { - matches.forEach(match => preservedVariables.add(match)); + matches.forEach((match) => preservedVariables.add(match)); } }); // Find common substrings - const substringFrequency = new Map(); + const substringFrequency = new Map< + string, + { count: number; urls: number[] } + >(); urls.forEach((url, urlIndex) => { // Clean URL by removing existing variables @@ -2435,30 +2448,39 @@ export class CollectionRequestService { }); // Split URL into meaningful parts - const parts = cleanUrl.split(/[\/\?&=]/).filter(part => part.length > 0); + const parts = cleanUrl + .split(/[\/\?&=]/) + .filter((part) => part.length > 0); // Generate substrings for (let i = 0; i < parts.length; i++) { for (let j = i + 1; j <= Math.min(parts.length, i + 4); j++) { - const substring = parts.slice(i, j).join('/'); + const substring = parts.slice(i, j).join("/"); // Skip invalid substrings - if (substring.includes('__VAR_') || + if ( + substring.includes("__VAR_") || substring.length < 3 || /^\d+$/.test(substring) || - substring.includes('%') || - substring.includes('=')) continue; + substring.includes("%") || + substring.includes("=") + ) + continue; // Find actual substring in original URL - const urlParts = url.split('/'); - let fullSubstring = ''; + const urlParts = url.split("/"); + let fullSubstring = ""; for (let k = 0; k < urlParts.length; k++) { for (let l = k + 1; l <= urlParts.length; l++) { - const testSubstring = urlParts.slice(k, l).join('/'); - if (testSubstring.includes(substring) && - !Array.from(preservedVariables).some(v => testSubstring.includes(v)) && - testSubstring.length >= 8) { + const testSubstring = urlParts.slice(k, l).join("/"); + if ( + testSubstring.includes(substring) && + !Array.from(preservedVariables).some((v) => + testSubstring.includes(v), + ) && + testSubstring.length >= 8 + ) { fullSubstring = testSubstring; break; } @@ -2486,15 +2508,17 @@ export class CollectionRequestService { const candidates = Array.from(substringFrequency.entries()) .filter(([substring, data]) => { - return data.count >= threshold && + return ( + data.count >= threshold && substring.length >= 8 && - !Array.from(preservedVariables).some(v => substring.includes(v)); + !Array.from(preservedVariables).some((v) => substring.includes(v)) + ); }) .map(([substring, data]) => ({ substring, count: data.count, length: substring.length, - priority: data.count * 1000 + substring.length + priority: data.count * 1000 + substring.length, })) .sort((a, b) => b.priority - a.priority); @@ -2505,8 +2529,10 @@ export class CollectionRequestService { let shouldInclude = true; for (const selected of selectedCandidates) { - if (candidate.substring.includes(selected.substring) || - selected.substring.includes(candidate.substring)) { + if ( + candidate.substring.includes(selected.substring) || + selected.substring.includes(candidate.substring) + ) { shouldInclude = false; break; } @@ -2564,14 +2590,21 @@ export class CollectionRequestService { const valueFrequencyByKey = new Map>(); const valueCountByKey: Record = {}; - const extractKeyValuePairs = (obj: any, parentKey = ''): Array<[string, string]> => { + const extractKeyValuePairs = ( + obj: any, + parentKey = "", + ): Array<[string, string]> => { const pairs: Array<[string, string]> = []; - if (typeof obj === 'string') { - if (obj.trim() && !existingVariablePattern.test(obj.trim())) { pairs.push([parentKey || 'body', obj.trim()]); } + if (typeof obj === "string") { + if (obj.trim() && !existingVariablePattern.test(obj.trim())) { + pairs.push([parentKey || "body", obj.trim()]); + } } else if (Array.isArray(obj)) { - obj.forEach((item) => pairs.push(...extractKeyValuePairs(item, parentKey))); - } else if (typeof obj === 'object' && obj !== null) { + obj.forEach((item) => + pairs.push(...extractKeyValuePairs(item, parentKey)), + ); + } else if (typeof obj === "object" && obj !== null) { for (const [k, v] of Object.entries(obj)) { pairs.push(...extractKeyValuePairs(v, k)); } @@ -2585,10 +2618,19 @@ export class CollectionRequestService { // Process urlencoded if (body.urlencoded) { for (const item of body.urlencoded) { - if (item.checked !== false && item.value?.trim() && !existingVariablePattern.test(item.value)) { + if ( + item.checked !== false && + item.value?.trim() && + !existingVariablePattern.test(item.value) + ) { const key = item.key.trim(); const value = item.value.trim(); - this.addToFrequencyMap(key, value, valueFrequencyByKey, valueCountByKey); + this.addToFrequencyMap( + key, + value, + valueFrequencyByKey, + valueCountByKey, + ); } } } @@ -2596,10 +2638,19 @@ export class CollectionRequestService { // Process formdata if (body.formdata?.text) { for (const item of body.formdata.text) { - if (item.checked !== false && item.value?.trim() && !existingVariablePattern.test(item.value)) { + if ( + item.checked !== false && + item.value?.trim() && + !existingVariablePattern.test(item.value) + ) { const key = item.key.trim(); const value = item.value.trim(); - this.addToFrequencyMap(key, value, valueFrequencyByKey, valueCountByKey); + this.addToFrequencyMap( + key, + value, + valueFrequencyByKey, + valueCountByKey, + ); } } } @@ -2610,7 +2661,12 @@ export class CollectionRequestService { const parsed = JSON.parse(body.raw); const keyVals = extractKeyValuePairs(parsed); for (const [key, value] of keyVals) { - this.addToFrequencyMap(key, value, valueFrequencyByKey, valueCountByKey); + this.addToFrequencyMap( + key, + value, + valueFrequencyByKey, + valueCountByKey, + ); } } catch { // Ignore parsing errors @@ -2618,11 +2674,21 @@ export class CollectionRequestService { } // Process other body types (websocket, socketio, graphql) - ['message', 'event', 'query', 'mutation', 'variables'].forEach(field => { - if (body[field]?.trim() && !existingVariablePattern.test(body[field])) { - this.addToFrequencyMap(field, body[field].trim(), valueFrequencyByKey, valueCountByKey); + ["message", "event", "query", "mutation", "variables"].forEach( + (field) => { + if ( + body[field]?.trim() && + !existingVariablePattern.test(body[field]) + ) { + this.addToFrequencyMap( + field, + body[field].trim(), + valueFrequencyByKey, + valueCountByKey, + ); } - }); + }, + ); } // Generate variables @@ -2635,7 +2701,7 @@ export class CollectionRequestService { for (const [value, count] of valMap.entries()) { if (count >= threshold) { - const cleanKey = key || 'body'; + const cleanKey = key || "body"; const varName = `${cleanKey}_var${keyVarCounters[key]++}`; result[varName] = value; } @@ -2658,7 +2724,9 @@ export class CollectionRequestService { * @returns * An object mapping generated variable names to their original string values. */ - public generateQueryVariables(paramGroups: Array>): Record { + public generateQueryVariables( + paramGroups: Array>, + ): Record { if (paramGroups.length === 0) return {}; const existingVariablePattern = /\{\{?[^}]+\}?\}/; // Matches {{VAR}}, {VAR} @@ -2668,7 +2736,8 @@ export class CollectionRequestService { // Count frequencies per key for (const group of paramGroups) { for (const param of group) { - if ( param.value?.trim() && + if ( + param.value?.trim() && !existingVariablePattern.test(param.value.trim()) // skip pre-existing vars ) { const key = param.key.trim(); @@ -2703,7 +2772,9 @@ export class CollectionRequestService { * @returns A record mapping generated variable names to header values. */ public generateHeaderVariables( - headerGroups: Array> + headerGroups: Array< + Array<{ key: string; value: string; checked: boolean }> + >, ): Record { if (headerGroups.length === 0) return {}; @@ -2745,7 +2816,6 @@ export class CollectionRequestService { return result; } - /** * Adds a key–value occurrence to a nested frequency map and updates its count. * @@ -2760,7 +2830,7 @@ export class CollectionRequestService { key: string, value: string, frequencyMap: Map>, - countMap: Record + countMap: Record, ) { if (!frequencyMap.has(key)) { frequencyMap.set(key, new Map()); @@ -2784,4 +2854,4 @@ export class CollectionRequestService { public getAdaptiveThreshold(count: number): number { return count <= 10 ? 3 : 5; } -} \ No newline at end of file +} diff --git a/src/modules/workspace/services/collection.service.ts b/src/modules/workspace/services/collection.service.ts index 5640e6c16..d0e317590 100644 --- a/src/modules/workspace/services/collection.service.ts +++ b/src/modules/workspace/services/collection.service.ts @@ -59,6 +59,7 @@ import { UserRepository } from "@src/modules/identity/repositories/user.reposito import { CollectionGenerateVariableDto } from "@src/modules/common/models/collection.model"; import { CollectionRequestService } from "./collection-request.service"; import { WorkspaceRole } from "@src/modules/common/enum/roles.enum"; +import { UserMetricsService } from "./userMetrics.service"; @Injectable() export class CollectionService { @@ -73,6 +74,7 @@ export class CollectionService { private readonly cryptoService: EncryptionService, private readonly userRepository: UserRepository, private readonly collectionRequestService: CollectionRequestService, + private readonly userMetricsService: UserMetricsService, ) {} async createCollection( @@ -123,6 +125,10 @@ export class CollectionService { workspaceId: createCollectionDto.workspaceId, }), }); + + // Track collection creation (fire-and-forget) + this.userMetricsService.onCollectionCreated(user._id.toString()); + return collection; } @@ -1597,10 +1603,7 @@ export class CollectionService { "Please provide collectionId and Generated Variables.", ); } - await this.workspaceService.IsWorkspaceAdminOrEditor( - workspaceId, - user._id, - ); + await this.workspaceService.IsWorkspaceAdminOrEditor(workspaceId, user._id); await this.checkPermission(workspaceId, user._id); const collectionDocument = await this.getCollection(collectionId); if (!collectionDocument) { diff --git a/src/modules/workspace/services/testflow-run.service.ts b/src/modules/workspace/services/testflow-run.service.ts index dd5aef3df..2b3e1d785 100644 --- a/src/modules/workspace/services/testflow-run.service.ts +++ b/src/modules/workspace/services/testflow-run.service.ts @@ -12,6 +12,7 @@ import { ConfigService } from "@nestjs/config"; import { WorkspaceRepository } from "../repositories/workspace.repository"; import { VariableDto } from "@src/modules/common/models/environment.model"; import { ObjectId } from "mongodb"; +import { UserMetricsService } from "./userMetrics.service"; @Injectable() export class TestflowRunService { @@ -20,6 +21,7 @@ export class TestflowRunService { private readonly environmentReposistory: EnvironmentRepository, private readonly configService: ConfigService, private readonly workspaceReposistory: WorkspaceRepository, + private readonly userMetricsService: UserMetricsService, ) {} private readonly logger = new Logger(TestflowRunService.name); @@ -118,6 +120,23 @@ export class TestflowRunService { "Content-Type": "application/json", }, }); + + // Fire-and-forget: record a successful testflow execution for user metrics + try { + const result = response?.data || {}; + const history = result.history || {}; + const successRequests = history.successRequests || 0; + if (user && user._id && successRequests > 0) { + const userIdStr = + typeof user._id === "string" ? user._id : user._id.toString(); + this.userMetricsService.onTestflowExecuted(userIdStr); + } + } catch (err) { + this.logger.warn( + `Failed to record testflow metric: ${err?.message || err}`, + ); + } + const finalResult = { result: response.data, environmentName: environmentData?.name, diff --git a/src/modules/workspace/services/updates.service.ts b/src/modules/workspace/services/updates.service.ts index 4993bc049..02f8c6a68 100644 --- a/src/modules/workspace/services/updates.service.ts +++ b/src/modules/workspace/services/updates.service.ts @@ -8,6 +8,7 @@ import { Updates } from "@src/modules/common/models/updates.model"; // ---- Repository import { UpdatesRepository } from "../repositories/updates.repository"; import { DecodedUserObject } from "@src/types/fastify"; +import { UserMetricsService } from "./userMetrics.service"; /** * Updates Service - Service responsible for handling operations related to updates. @@ -17,8 +18,12 @@ export class UpdatesService { /** * Constructor to initialize UpdatesService with required dependencies. * @param updatesRepository - Injected UpdatesRepository for database operations. + * @param userMetricsService - Injected UserMetricsService for tracking metrics. */ - constructor(private readonly updatesRepository: UpdatesRepository) {} + constructor( + private readonly updatesRepository: UpdatesRepository, + private readonly userMetricsService: UserMetricsService, + ) {} /** * Adds a new update to the database. @@ -36,6 +41,10 @@ export class UpdatesService { detailsUpdatedBy: user.name, }; const response = await this.updatesRepository.addUpdate(modifiedUpdate); + + // Track execution activity (fire-and-forget) + this.userMetricsService.onExecutionActivity(user._id.toString()); + return response; } diff --git a/src/modules/workspace/services/userMetrics.service.ts b/src/modules/workspace/services/userMetrics.service.ts new file mode 100644 index 000000000..f86af3069 --- /dev/null +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -0,0 +1,192 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { UserMetricsRepository } from "../repositories/userMetrics.repository"; + +/** + * UserMetrics Service + * Provides event-driven methods to update user metrics. + * All operations are fire-and-forget to avoid blocking the main request flow. + */ +@Injectable() +export class UserMetricsService { + private readonly logger = new Logger(UserMetricsService.name); + + constructor(private readonly userMetricsRepository: UserMetricsRepository) {} + + /** + * Track when a user creates an API endpoint. + * Fire-and-forget: does not block the caller. + * + * @param userId The user who created the API + */ + async onApiCreated(userId: string): Promise { + this.trackMetric(userId, { apisCreated: 1 }, "onApiCreated"); + } + + /** + * Track when a user executes a testflow. + * Fire-and-forget: does not block the caller. + * + * @param userId The user who executed the testflow + */ + async onTestflowExecuted(userId: string): Promise { + this.trackMetric(userId, { testflowsExecuted: 1 }, "onTestflowExecuted"); + } + + /** + * Track when a user creates a collection. + * Fire-and-forget: does not block the caller. + * + * @param userId The user who created the collection + */ + async onCollectionCreated(userId: string): Promise { + this.trackMetric(userId, { collectionsCount: 1 }, "onCollectionCreated"); + } + + /** + * Track when a user is active in a workspace. + * Fire-and-forget: does not block the caller. + * + * @param userId The user who was active in the workspace + */ + async onWorkspaceActive(userId: string): Promise { + this.trackMetric(userId, { activeWorkspaces: 1 }, "onWorkspaceActive"); + } + + /** + * Track general execution activity (updates, actions). + * Fire-and-forget: does not block the caller. + * + * @param userId The user who performed the activity + */ + async onExecutionActivity(userId: string): Promise { + this.trackMetric(userId, { totalExecutions: 1 }, "onExecutionActivity"); + } + + /** + * Internal method to track a metric. + * Wraps the repository call in try-catch to ensure non-blocking behavior. + * Logs errors without throwing to avoid disrupting the main application flow. + * + * @param userId The user ID to track + * @param payload The metrics to increment + * @param eventName Name of the event for logging purposes + */ + private trackMetric( + userId: string, + payload: { + apisCreated?: number; + testflowsExecuted?: number; + collectionsCount?: number; + activeWorkspaces?: number; + totalExecutions?: number; + }, + eventName: string, + ): void { + // Fire-and-forget: do not await + this.incrementMetricsSafe(userId, payload, eventName); + } + + /** + * Safely increment metrics without blocking. + * Catches and logs any errors. + */ + private async incrementMetricsSafe( + userId: string, + payload: { + apisCreated?: number; + testflowsExecuted?: number; + collectionsCount?: number; + activeWorkspaces?: number; + totalExecutions?: number; + }, + eventName: string, + ): Promise { + try { + if (!userId) { + return; + } + + const weekStart = this.userMetricsRepository.getWeekStart(); + + await this.userMetricsRepository.incrementMetrics( + userId, + weekStart, + payload, + ); + } catch (error) { + // Log error but do not throw - this should never block the main flow + this.logger.error( + `Failed to track metric [${eventName}] for user ${userId}: ${error.message}`, + error.stack, + ); + } + } + + /** + * Batch track multiple events at once. + * Useful for processing multiple activities in a single operation. + * + * @param operations Array of { userId, event } pairs + */ + async trackBatch( + operations: Array<{ + userId: string; + event: + | "apiCreated" + | "testflowExecuted" + | "collectionCreated" + | "workspaceActive" + | "executionActivity"; + }>, + ): Promise { + try { + if (operations.length === 0) { + return; + } + + const weekStart = this.userMetricsRepository.getWeekStart(); + + const metricsOperations = operations.map(({ userId, event }) => { + let payload: { + apisCreated?: number; + testflowsExecuted?: number; + collectionsCount?: number; + activeWorkspaces?: number; + totalExecutions?: number; + }; + + switch (event) { + case "apiCreated": + payload = { apisCreated: 1 }; + break; + case "testflowExecuted": + payload = { testflowsExecuted: 1 }; + break; + case "collectionCreated": + payload = { collectionsCount: 1 }; + break; + case "workspaceActive": + payload = { activeWorkspaces: 1 }; + break; + case "executionActivity": + payload = { totalExecutions: 1 }; + break; + default: + payload = {}; + } + + return { userId, payload }; + }); + + await this.userMetricsRepository.bulkIncrementMetrics( + metricsOperations, + weekStart, + ); + } catch (error) { + this.logger.error( + `Failed to track batch metrics: ${error.message}`, + error.stack, + ); + } + } +} diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts index 903b519f8..66daa70f0 100644 --- a/src/modules/workspace/workspace.module.ts +++ b/src/modules/workspace/workspace.module.ts @@ -60,6 +60,7 @@ import { TeamUserService } from "../identity/services/team-user.service"; import { SalesEmailService } from "./services/sales-email.service"; import { PricingService } from "./services/pricing.repository"; import { WeeklyDigestService } from "./services/weekly-digest.service"; +import { UserMetricsService } from "./services/userMetrics.service"; // ---- Gateway import { @@ -153,6 +154,7 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; WeeklyDigestScheduler, WeeklyDigestService, UserMetricsRepository, + UserMetricsService, ], exports: [ CollectionService, From 1eb1bad9fd58c4739c92c2ce384c9e2878acf0d7 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 24 Mar 2026 12:21:50 +0530 Subject: [PATCH 03/21] feat: user metrics tracking [SPRW-3110] --- src/modules/workspace/services/workspace.service.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/modules/workspace/services/workspace.service.ts b/src/modules/workspace/services/workspace.service.ts index 52137f620..cc0ef3ea2 100644 --- a/src/modules/workspace/services/workspace.service.ts +++ b/src/modules/workspace/services/workspace.service.ts @@ -57,6 +57,7 @@ import { EmailService } from "@src/modules/common/services/email.service"; import { TestflowInfoDto } from "@src/modules/common/models/testflow.model"; import { DecodedUserObject } from "@src/types/fastify"; import { isValidName } from "@src/modules/common/util/validate.name.util"; +import { UserMetricsService } from "./userMetrics.service"; /** * Workspace Service @@ -73,6 +74,7 @@ export class WorkspaceService { private readonly configService: ConfigService, private readonly producerService: ProducerService, private readonly emailService: EmailService, + private readonly userMetricsService: UserMetricsService, ) {} async get(id: string): Promise> { @@ -405,6 +407,9 @@ export class WorkspaceService { ); } + // Track workspace activity (fire-and-forget) + this.userMetricsService.onWorkspaceActive(user._id.toString()); + return response; } @@ -495,6 +500,9 @@ export class WorkspaceService { }), }); } + + // Track workspace activity (fire-and-forget) + this.userMetricsService.onWorkspaceActive(user._id.toString()); return data; } From aced60ffa9e055dce305af9bab08d35bacc719db Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 24 Mar 2026 13:14:06 +0530 Subject: [PATCH 04/21] fix: replace weekly digest aggregation with precomputed user metrics [SPRW-3110] --- .../services/weekly-digest.service.ts | 98 ++++++++----------- 1 file changed, 43 insertions(+), 55 deletions(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 432e303dd..b86ab4765 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -1,14 +1,14 @@ import { Injectable, Logger } from "@nestjs/common"; import { UserRepository } from "@src/modules/identity/repositories/user.repository"; -import { TestflowRepository } from "../repositories/testflow.repository"; -import { WorkspaceRepository } from "../repositories/workspace.repository"; -import { CollectionRepository } from "../repositories/collection.repository"; +// testflow/workspace/collection repositories are not needed here; metrics come from UserMetricsRepository import { EmailService } from "@src/modules/common/services/email.service"; import { ConfigService } from "@nestjs/config"; import { UpdatesRepository } from "../repositories/updates.repository"; import { UserInvitesRepository } from "@src/modules/identity/repositories/userInvites.repository"; +import { UserMetricsRepository } from "../repositories/userMetrics.repository"; import { ObjectId, WithId } from "mongodb"; import { User } from "@src/modules/common/models/user.model"; +import { UserMetricsData } from "@src/modules/common/models/user-metrics.model"; /** Configuration for batch processing and concurrency */ interface BatchConfig { @@ -19,7 +19,6 @@ interface BatchConfig { /** Per-user metrics computed via batch aggregation */ interface UserMetrics { activeWorkspaces: number; - newWorkspaces: number; collectionsCount: number; apisCount: number; testflowExecutions: number; @@ -50,10 +49,8 @@ export class WeeklyDigestService { constructor( private readonly userRepository: UserRepository, - private readonly testflowRepository: TestflowRepository, - private readonly workspaceRepository: WorkspaceRepository, - private readonly collectionRepository: CollectionRepository, private readonly updatesRepository: UpdatesRepository, + private readonly userMetricsRepository: UserMetricsRepository, private readonly emailService: EmailService, private readonly configService: ConfigService, private readonly userInvitesRepository: UserInvitesRepository, @@ -117,7 +114,7 @@ export class WeeklyDigestService { const userIds = usersBatch.map((u) => u._id.toString()); const emails = usersBatch.map((u) => u.email); - // Fetch per-user data and metrics in bulk using aggregation + // Fetch per-user data using precomputed user metrics (no aggregation) const userEmailDataMap = await this.getMetricsForUserBatch( start, end, @@ -216,7 +213,7 @@ export class WeeklyDigestService { /** * Fetch per-user metrics for a batch of users using bulk aggregation queries. - * Computes workspace, collection, API, and testflow metrics using MongoDB aggregation. + * Avoids heavy aggregation and uses O(1) lookups. * Avoids N+1 queries by fetching all data in bulk. */ private async getMetricsForUserBatch( @@ -226,63 +223,55 @@ export class WeeklyDigestService { emails: string[], users: WithId[], ): Promise> { - // Build user-to-workspaces map for testflow metrics - const userWorkspacesMap = new Map(); - for (const user of users) { - const workspaceIds = (user.workspaces || []).map((w) => w.workspaceId); - userWorkspacesMap.set(user._id.toString(), workspaceIds); + // Compute weekStart once per batch using the repository helper + const weekStart = this.userMetricsRepository.getWeekStart(); + + // If userIds is very large, split into chunks to keep queries manageable + const maxChunk = userIds.length > 1000 ? 800 : userIds.length; + const chunks: string[][] = []; + for (let i = 0; i < userIds.length; i += maxChunk) { + chunks.push(userIds.slice(i, i + maxChunk)); } - // Fetch all metrics in parallel using aggregation pipelines - const [workspaceMetricsMap, testflowMetricsMap, updatesMap, invitesMap] = - await Promise.all([ - this.workspaceRepository.getWorkspaceMetricsForUserBatch( - userIds, - start, - end, - ), - this.testflowRepository.getTestflowMetricsForUserBatch( - userWorkspacesMap, - start, - end, - ), - this.updatesRepository.getUpdatesForBatch(start, end, userIds), - this.userInvitesRepository.getPendingInvitesForBatch( - start, - end, - emails, - ), - ]); - - // Build the email data map for each user + // Fetch metrics for all users in the batch (may run multiple queries if chunked) + const metricsPromises = chunks.map((chunk) => + this.userMetricsRepository.getMetricsForUsers(chunk, weekStart), + ); + + // Also fetch lightweight updates and invites in parallel + const [metricsMapsArray, updatesMap, invitesMap] = await Promise.all([ + Promise.all(metricsPromises), + this.updatesRepository.getUpdatesForBatch(start, end, userIds), + this.userInvitesRepository.getPendingInvitesForBatch(start, end, emails), + ]); + + // Merge chunked metrics maps into a single map + const mergedMetricsMap = new Map(); + for (const map of metricsMapsArray) { + for (const [key, value] of map.entries()) { + mergedMetricsMap.set(key, value); + } + } + this.logger.log( + `Fetched metrics for ${userIds.length} users (chunks: ${chunks.length})`, + ); + const userEmailDataMap = new Map(); for (const user of users) { - if (user.isWeeklyDigestEnabled === false) { - continue; - } + if (user.isWeeklyDigestEnabled === false) continue; const userId = user._id.toString(); const collaborationUpdates = updatesMap.get(userId) || []; const pendingActions = invitesMap.get(user.email) || []; - // Get workspace metrics for this user - const workspaceMetrics = workspaceMetricsMap.get(userId) || { - activeWorkspaces: 0, - newWorkspaces: 0, - collectionsCount: 0, - apisCount: 0, - }; - - // Get testflow metrics for this user - const testflowExecutions = testflowMetricsMap.get(userId) || 0; + const metricsData = mergedMetricsMap.get(userId); const metrics: UserMetrics = { - activeWorkspaces: workspaceMetrics.activeWorkspaces, - newWorkspaces: workspaceMetrics.newWorkspaces, - collectionsCount: workspaceMetrics.collectionsCount, - apisCount: workspaceMetrics.apisCount, - testflowExecutions, + activeWorkspaces: metricsData?.activeWorkspaces ?? 0, + collectionsCount: metricsData?.collectionsCount ?? 0, + apisCount: metricsData?.apisCreated ?? 0, + testflowExecutions: metricsData?.testflowsExecuted ?? 0, }; userEmailDataMap.set(userId, { @@ -344,7 +333,6 @@ export class WeeklyDigestService { }, // Per-user metrics computed via batch aggregation metrics: { - newWorkspaces: metrics.newWorkspaces, newCollections: metrics.collectionsCount, apisCreated: metrics.apisCount, testflowsExecuted: metrics.testflowExecutions, From 2dc20e7c1907e85518eb99ba243d1834c81e868e Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 24 Mar 2026 16:57:19 +0530 Subject: [PATCH 05/21] fix: minimal fixes [SPRW-3110] --- .../workspace/repositories/userMetrics.repository.ts | 10 ---------- src/modules/workspace/services/userMetrics.service.ts | 5 +++++ .../workspace/services/weekly-digest.service.ts | 8 ++++---- src/modules/workspace/workspace.module.ts | 1 + 4 files changed, 10 insertions(+), 14 deletions(-) diff --git a/src/modules/workspace/repositories/userMetrics.repository.ts b/src/modules/workspace/repositories/userMetrics.repository.ts index 6c0912bb6..79fe5397d 100644 --- a/src/modules/workspace/repositories/userMetrics.repository.ts +++ b/src/modules/workspace/repositories/userMetrics.repository.ts @@ -111,11 +111,6 @@ export class UserMetricsRepository implements OnModuleInit { $setOnInsert: { userId, weekStart, - totalExecutions: 0, - apisCreated: 0, - collectionsCount: 0, - activeWorkspaces: 0, - testflowsExecuted: 0, }, }, { upsert: true }, @@ -181,11 +176,6 @@ export class UserMetricsRepository implements OnModuleInit { $setOnInsert: { userId, weekStart, - totalExecutions: 0, - apisCreated: 0, - collectionsCount: 0, - activeWorkspaces: 0, - testflowsExecuted: 0, }, }, upsert: true, diff --git a/src/modules/workspace/services/userMetrics.service.ts b/src/modules/workspace/services/userMetrics.service.ts index f86af3069..3dcc5ecac 100644 --- a/src/modules/workspace/services/userMetrics.service.ts +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -19,6 +19,7 @@ export class UserMetricsService { * @param userId The user who created the API */ async onApiCreated(userId: string): Promise { + this.logger.log(`Metrics update: API created for ${userId}`); this.trackMetric(userId, { apisCreated: 1 }, "onApiCreated"); } @@ -29,6 +30,7 @@ export class UserMetricsService { * @param userId The user who executed the testflow */ async onTestflowExecuted(userId: string): Promise { + this.logger.log(`Metrics update: Testflow executed for ${userId}`); this.trackMetric(userId, { testflowsExecuted: 1 }, "onTestflowExecuted"); } @@ -39,6 +41,7 @@ export class UserMetricsService { * @param userId The user who created the collection */ async onCollectionCreated(userId: string): Promise { + this.logger.log(`Metrics update: Collection created for ${userId}`); this.trackMetric(userId, { collectionsCount: 1 }, "onCollectionCreated"); } @@ -49,6 +52,7 @@ export class UserMetricsService { * @param userId The user who was active in the workspace */ async onWorkspaceActive(userId: string): Promise { + this.logger.log(`Metrics update: Workspace active for ${userId}`); this.trackMetric(userId, { activeWorkspaces: 1 }, "onWorkspaceActive"); } @@ -59,6 +63,7 @@ export class UserMetricsService { * @param userId The user who performed the activity */ async onExecutionActivity(userId: string): Promise { + this.logger.log(`Metrics update: Execution activity for ${userId}`); this.trackMetric(userId, { totalExecutions: 1 }, "onExecutionActivity"); } diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index b86ab4765..81474e474 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -41,7 +41,7 @@ interface UserEmailData { @Injectable() export class WeeklyDigestService { - private static readonly QA_DIGEST_EMAIL = ""; + private static readonly QA_DIGEST_EMAIL = "mayank9@yopmail.com"; private static readonly DEFAULT_BATCH_SIZE = 100; private static readonly DEFAULT_EMAIL_CONCURRENCY = 5; @@ -71,11 +71,11 @@ export class WeeklyDigestService { const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; - // Time range for the digest (last 30 mins for testing, or use getLastWeekRange() for production) + // Time range for the digest (last 1 min for testing, or use getLastWeekRange() for production) const end = new Date(); - const start = new Date(end.getTime() - 30 * 60 * 1000); + const start = new Date(end.getTime() - 1 * 60 * 1000); const prevEnd = new Date(start); - const prevStart = new Date(prevEnd.getTime() - 30 * 60 * 1000); + const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); // Fetch lightweight global activity graph (only updates collection, not heavy) const activityGraph = await this.fetchActivityGraph( diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts index 66daa70f0..075831f3b 100644 --- a/src/modules/workspace/workspace.module.ts +++ b/src/modules/workspace/workspace.module.ts @@ -184,6 +184,7 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; PricingService, PricingRepository, UserMetricsRepository, + UserMetricsService, ], controllers: [ WorkSpaceController, From 7a89f08d216c7a8388250a8c5e3773e0bf777fec Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 24 Mar 2026 18:22:50 +0530 Subject: [PATCH 06/21] fix: new workspaces count [SPRW-3110] --- .../common/models/user-metrics.model.ts | 9 ++++ .../repositories/userMetrics.repository.ts | 16 +++++++ .../schedulers/weekly-digest.scheduler.ts | 8 ++-- .../workspace/services/userMetrics.service.ts | 48 ++++++++++++++++++- .../services/weekly-digest.service.ts | 23 +++++++-- .../workspace/services/workspace.service.ts | 4 +- 6 files changed, 95 insertions(+), 13 deletions(-) diff --git a/src/modules/common/models/user-metrics.model.ts b/src/modules/common/models/user-metrics.model.ts index 1d524fe48..13f1bae42 100644 --- a/src/modules/common/models/user-metrics.model.ts +++ b/src/modules/common/models/user-metrics.model.ts @@ -54,6 +54,13 @@ export class UserMetrics { @IsOptional() activeWorkspaces?: number; + /** + * Number of new workspaces created by the user this week. + */ + @IsNumber() + @IsOptional() + newWorkspaces?: number; + /** * Number of testflows executed by the user this week. */ @@ -78,6 +85,7 @@ export interface IncrementMetricsPayload { apisCreated?: number; collectionsCount?: number; activeWorkspaces?: number; + newWorkspaces?: number; testflowsExecuted?: number; } @@ -91,6 +99,7 @@ export interface UserMetricsData { apisCreated: number; collectionsCount: number; activeWorkspaces: number; + newWorkspaces: number; testflowsExecuted: number; updatedAt: Date; } diff --git a/src/modules/workspace/repositories/userMetrics.repository.ts b/src/modules/workspace/repositories/userMetrics.repository.ts index 79fe5397d..d993bc47b 100644 --- a/src/modules/workspace/repositories/userMetrics.repository.ts +++ b/src/modules/workspace/repositories/userMetrics.repository.ts @@ -97,6 +97,9 @@ export class UserMetricsRepository implements OnModuleInit { if (payload.testflowsExecuted !== undefined) { incPayload.testflowsExecuted = payload.testflowsExecuted; } + if (payload.newWorkspaces !== undefined) { + incPayload.newWorkspaces = payload.newWorkspaces; + } // Skip if no metrics to increment if (Object.keys(incPayload).length === 0) { @@ -166,6 +169,9 @@ export class UserMetricsRepository implements OnModuleInit { if (payload.testflowsExecuted !== undefined) { incPayload.testflowsExecuted = payload.testflowsExecuted; } + if (payload.newWorkspaces !== undefined) { + incPayload.newWorkspaces = payload.newWorkspaces; + } return { updateOne: { @@ -228,6 +234,10 @@ export class UserMetricsRepository implements OnModuleInit { existing.testflowsExecuted = (existing.testflowsExecuted || 0) + payload.testflowsExecuted; } + if (payload.newWorkspaces !== undefined) { + existing.newWorkspaces = + (existing.newWorkspaces || 0) + payload.newWorkspaces; + } } } @@ -265,6 +275,7 @@ export class UserMetricsRepository implements OnModuleInit { apisCreated: 1, collectionsCount: 1, activeWorkspaces: 1, + newWorkspaces: 1, testflowsExecuted: 1, updatedAt: 1, }, @@ -282,6 +293,7 @@ export class UserMetricsRepository implements OnModuleInit { apisCreated: result.apisCreated || 0, collectionsCount: result.collectionsCount || 0, activeWorkspaces: result.activeWorkspaces || 0, + newWorkspaces: result.newWorkspaces || 0, testflowsExecuted: result.testflowsExecuted || 0, updatedAt: result.updatedAt || new Date(), }); @@ -330,6 +342,7 @@ export class UserMetricsRepository implements OnModuleInit { apisCreated: result.apisCreated || 0, collectionsCount: result.collectionsCount || 0, activeWorkspaces: result.activeWorkspaces || 0, + newWorkspaces: result.newWorkspaces || 0, testflowsExecuted: result.testflowsExecuted || 0, updatedAt: result.updatedAt || new Date(), }; @@ -364,6 +377,9 @@ export class UserMetricsRepository implements OnModuleInit { if (metrics.activeWorkspaces !== undefined) { setPayload.activeWorkspaces = metrics.activeWorkspaces; } + if (metrics.newWorkspaces !== undefined) { + setPayload.newWorkspaces = metrics.newWorkspaces; + } if (metrics.testflowsExecuted !== undefined) { setPayload.testflowsExecuted = metrics.testflowsExecuted; } diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index fc81a6194..3c27c31ed 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -9,10 +9,10 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} // Disabled until we are sure it works correctly and doesn't cause issues with the database load. We can enable it later once we have confidence in its stability. - // @Cron(CronExpression.EVERY_30_MINUTES, { - // name: "weekly-digest", - // waitForCompletion: true, - // }) + @Cron(CronExpression.EVERY_MINUTE, { + name: "weekly-digest", + waitForCompletion: true, + }) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/userMetrics.service.ts b/src/modules/workspace/services/userMetrics.service.ts index 3dcc5ecac..c1b526c0a 100644 --- a/src/modules/workspace/services/userMetrics.service.ts +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -31,7 +31,16 @@ export class UserMetricsService { */ async onTestflowExecuted(userId: string): Promise { this.logger.log(`Metrics update: Testflow executed for ${userId}`); - this.trackMetric(userId, { testflowsExecuted: 1 }, "onTestflowExecuted"); + try { + const weekStart = this.userMetricsRepository.getWeekStart(); + await this.userMetricsRepository.incrementMetrics(userId, weekStart, { + testflowsExecuted: 1, + }); + } catch (error) { + this.logger.error( + `Failed to increment testflowsExecuted for ${userId}: ${error?.message || error}`, + ); + } } /** @@ -53,7 +62,35 @@ export class UserMetricsService { */ async onWorkspaceActive(userId: string): Promise { this.logger.log(`Metrics update: Workspace active for ${userId}`); - this.trackMetric(userId, { activeWorkspaces: 1 }, "onWorkspaceActive"); + try { + const weekStart = this.userMetricsRepository.getWeekStart(); + await this.userMetricsRepository.incrementMetrics(userId, weekStart, { + activeWorkspaces: 1, + }); + } catch (error) { + this.logger.error( + `Failed to increment activeWorkspaces for ${userId}: ${error?.message || error}`, + ); + } + } + + /** + * Track when a user creates a workspace. + * Increments both newWorkspaces and activeWorkspaces for the week. + */ + async onWorkspaceCreated(userId: string): Promise { + this.logger.log(`Metrics update: Workspace created for ${userId}`); + try { + const weekStart = this.userMetricsRepository.getWeekStart(); + await this.userMetricsRepository.incrementMetrics(userId, weekStart, { + newWorkspaces: 1, + activeWorkspaces: 1, + }); + } catch (error) { + this.logger.error( + `Failed to increment newWorkspaces for ${userId}: ${error?.message || error}`, + ); + } } /** @@ -83,6 +120,7 @@ export class UserMetricsService { testflowsExecuted?: number; collectionsCount?: number; activeWorkspaces?: number; + newWorkspaces?: number; totalExecutions?: number; }, eventName: string, @@ -102,6 +140,7 @@ export class UserMetricsService { testflowsExecuted?: number; collectionsCount?: number; activeWorkspaces?: number; + newWorkspaces?: number; totalExecutions?: number; }, eventName: string, @@ -141,6 +180,7 @@ export class UserMetricsService { | "testflowExecuted" | "collectionCreated" | "workspaceActive" + | "workspaceCreated" | "executionActivity"; }>, ): Promise { @@ -157,6 +197,7 @@ export class UserMetricsService { testflowsExecuted?: number; collectionsCount?: number; activeWorkspaces?: number; + newWorkspaces?: number; totalExecutions?: number; }; @@ -173,6 +214,9 @@ export class UserMetricsService { case "workspaceActive": payload = { activeWorkspaces: 1 }; break; + case "workspaceCreated": + payload = { newWorkspaces: 1, activeWorkspaces: 1 }; + break; case "executionActivity": payload = { totalExecutions: 1 }; break; diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 81474e474..531df4c06 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -22,6 +22,7 @@ interface UserMetrics { collectionsCount: number; apisCount: number; testflowExecutions: number; + newWorkspaces: number; } /** Activity graph data for the digest */ @@ -265,13 +266,24 @@ export class WeeklyDigestService { const collaborationUpdates = updatesMap.get(userId) || []; const pendingActions = invitesMap.get(user.email) || []; - const metricsData = mergedMetricsMap.get(userId); + const metricsData: UserMetricsData = mergedMetricsMap.get(userId) ?? { + userId, + weekStart, + totalExecutions: 0, + apisCreated: 0, + collectionsCount: 0, + activeWorkspaces: 0, + newWorkspaces: 0, + testflowsExecuted: 0, + updatedAt: new Date(), + }; const metrics: UserMetrics = { - activeWorkspaces: metricsData?.activeWorkspaces ?? 0, - collectionsCount: metricsData?.collectionsCount ?? 0, - apisCount: metricsData?.apisCreated ?? 0, - testflowExecutions: metricsData?.testflowsExecuted ?? 0, + activeWorkspaces: metricsData.activeWorkspaces || 0, + newWorkspaces: metricsData.newWorkspaces || 0, + collectionsCount: metricsData.collectionsCount || 0, + apisCount: metricsData.apisCreated || 0, + testflowExecutions: metricsData.testflowsExecuted || 0, }; userEmailDataMap.set(userId, { @@ -333,6 +345,7 @@ export class WeeklyDigestService { }, // Per-user metrics computed via batch aggregation metrics: { + newWorkspaces: metrics.newWorkspaces, newCollections: metrics.collectionsCount, apisCreated: metrics.apisCount, testflowsExecuted: metrics.testflowExecutions, diff --git a/src/modules/workspace/services/workspace.service.ts b/src/modules/workspace/services/workspace.service.ts index cc0ef3ea2..eb9e0e5a2 100644 --- a/src/modules/workspace/services/workspace.service.ts +++ b/src/modules/workspace/services/workspace.service.ts @@ -407,8 +407,8 @@ export class WorkspaceService { ); } - // Track workspace activity (fire-and-forget) - this.userMetricsService.onWorkspaceActive(user._id.toString()); + // Track workspace creation (fire-and-forget) + this.userMetricsService.onWorkspaceCreated(user._id.toString()); return response; } From 046ec3b34338a9e275cc644cdba6baf8e06d3474 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Tue, 24 Mar 2026 19:24:53 +0530 Subject: [PATCH 07/21] fix: testflow execution and pending invite fixed [SPRW-3110] --- .../repositories/userInvites.repository.ts | 1 - .../schedulers/weekly-digest.scheduler.ts | 8 ++--- .../workspace/services/testflow.service.ts | 30 ++++++++++++++----- 3 files changed, 27 insertions(+), 12 deletions(-) diff --git a/src/modules/identity/repositories/userInvites.repository.ts b/src/modules/identity/repositories/userInvites.repository.ts index 362a6fa67..2142162e3 100644 --- a/src/modules/identity/repositories/userInvites.repository.ts +++ b/src/modules/identity/repositories/userInvites.repository.ts @@ -101,7 +101,6 @@ export class UserInvitesRepository { .aggregate([ { $match: { - createdAt: { $gte: start, $lte: end }, email: { $in: emails }, }, }, diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index 3c27c31ed..0ed3e1711 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -9,10 +9,10 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} // Disabled until we are sure it works correctly and doesn't cause issues with the database load. We can enable it later once we have confidence in its stability. - @Cron(CronExpression.EVERY_MINUTE, { - name: "weekly-digest", - waitForCompletion: true, - }) + // @Cron(CronExpression.EVERY_MINUTE, { + // name: "weekly-digest", + // waitForCompletion: true, + // }) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/testflow.service.ts b/src/modules/workspace/services/testflow.service.ts index 38b4342cb..7a117e7d9 100644 --- a/src/modules/workspace/services/testflow.service.ts +++ b/src/modules/workspace/services/testflow.service.ts @@ -71,6 +71,7 @@ import { UserRepository } from "@src/modules/identity/repositories/user.reposito import { EnvironmentRepository } from "../repositories/environment.repository"; import { Collections } from "@src/modules/common/enum/database.collection.enum"; import { TeamRepository } from "@src/modules/identity/repositories/team.repository"; +import { UserMetricsService } from "./userMetrics.service"; /** * Testflow Service @@ -90,6 +91,7 @@ export class TestflowService implements OnModuleInit { private readonly userReposistory: UserRepository, private readonly environmentReposistory: EnvironmentRepository, private readonly teamReposistory: TeamRepository, + private readonly userMetricsService: UserMetricsService, ) {} async getNextFutureCronExpression( @@ -100,15 +102,15 @@ export class TestflowService implements OnModuleInit { const parts = pastCron.trim().split(/\s+/); if (parts.length !== 6) return pastCron; - let second = parseInt(parts[0], 10); - let minute = parseInt(parts[1], 10); - let hour = parseInt(parts[2], 10); - let day = parseInt(parts[3], 10); - let month = parseInt(parts[4], 10) - 1; + const second = parseInt(parts[0], 10); + const minute = parseInt(parts[1], 10); + const hour = parseInt(parts[2], 10); + const day = parseInt(parts[3], 10); + const month = parseInt(parts[4], 10) - 1; // Start from the past time - let now = new Date(); - let next = new Date( + const now = new Date(); + const next = new Date( Date.UTC(now.getUTCFullYear(), month, day, hour, minute, second, 0), ); @@ -429,6 +431,20 @@ export class TestflowService implements OnModuleInit { currentWorkspaceObject, updateWorkspaceData, ); + // Fire-and-forget: track testflow creation as an execution metric + try { + if (user && user._id) { + const userIdStr = + typeof user._id === "string" ? user._id : user._id.toString(); + this.userMetricsService.onTestflowExecuted(userIdStr); + } + } catch (err) { + // swallow errors - metric tracking must not block the flow + this.logger.warn( + `userMetrics onTestflowExecuted failed: ${err?.message || err}`, + ); + } + return testflow; } From 5450aad31f4743b0f7218bb408cff4446b84a599 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 25 Mar 2026 11:45:34 +0530 Subject: [PATCH 08/21] feat: add in-memory buffering with concurrency control[SPRW-3110] --- .../schedulers/weekly-digest.scheduler.ts | 8 +- .../workspace/services/userMetrics.service.ts | 65 +++----- .../services/userMetricsBuffer.service.ts | 144 ++++++++++++++++++ src/modules/workspace/workspace.module.ts | 3 + 4 files changed, 168 insertions(+), 52 deletions(-) create mode 100644 src/modules/workspace/services/userMetricsBuffer.service.ts diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index 0ed3e1711..3c27c31ed 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -9,10 +9,10 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} // Disabled until we are sure it works correctly and doesn't cause issues with the database load. We can enable it later once we have confidence in its stability. - // @Cron(CronExpression.EVERY_MINUTE, { - // name: "weekly-digest", - // waitForCompletion: true, - // }) + @Cron(CronExpression.EVERY_MINUTE, { + name: "weekly-digest", + waitForCompletion: true, + }) async handleWeeklyDigest() { this.logger.log("Starting Weekly Digest Job..."); diff --git a/src/modules/workspace/services/userMetrics.service.ts b/src/modules/workspace/services/userMetrics.service.ts index c1b526c0a..e52e90711 100644 --- a/src/modules/workspace/services/userMetrics.service.ts +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -1,5 +1,6 @@ import { Injectable, Logger } from "@nestjs/common"; import { UserMetricsRepository } from "../repositories/userMetrics.repository"; +import { UserMetricsBufferService } from "./userMetricsBuffer.service"; /** * UserMetrics Service @@ -10,7 +11,10 @@ import { UserMetricsRepository } from "../repositories/userMetrics.repository"; export class UserMetricsService { private readonly logger = new Logger(UserMetricsService.name); - constructor(private readonly userMetricsRepository: UserMetricsRepository) {} + constructor( + private readonly userMetricsRepository: UserMetricsRepository, + private readonly userMetricsBufferService: UserMetricsBufferService, + ) {} /** * Track when a user creates an API endpoint. @@ -30,17 +34,7 @@ export class UserMetricsService { * @param userId The user who executed the testflow */ async onTestflowExecuted(userId: string): Promise { - this.logger.log(`Metrics update: Testflow executed for ${userId}`); - try { - const weekStart = this.userMetricsRepository.getWeekStart(); - await this.userMetricsRepository.incrementMetrics(userId, weekStart, { - testflowsExecuted: 1, - }); - } catch (error) { - this.logger.error( - `Failed to increment testflowsExecuted for ${userId}: ${error?.message || error}`, - ); - } + this.trackMetric(userId, { testflowsExecuted: 1 }, "onTestflowExecuted"); } /** @@ -61,17 +55,7 @@ export class UserMetricsService { * @param userId The user who was active in the workspace */ async onWorkspaceActive(userId: string): Promise { - this.logger.log(`Metrics update: Workspace active for ${userId}`); - try { - const weekStart = this.userMetricsRepository.getWeekStart(); - await this.userMetricsRepository.incrementMetrics(userId, weekStart, { - activeWorkspaces: 1, - }); - } catch (error) { - this.logger.error( - `Failed to increment activeWorkspaces for ${userId}: ${error?.message || error}`, - ); - } + this.trackMetric(userId, { activeWorkspaces: 1 }, "onWorkspaceActive"); } /** @@ -79,18 +63,11 @@ export class UserMetricsService { * Increments both newWorkspaces and activeWorkspaces for the week. */ async onWorkspaceCreated(userId: string): Promise { - this.logger.log(`Metrics update: Workspace created for ${userId}`); - try { - const weekStart = this.userMetricsRepository.getWeekStart(); - await this.userMetricsRepository.incrementMetrics(userId, weekStart, { - newWorkspaces: 1, - activeWorkspaces: 1, - }); - } catch (error) { - this.logger.error( - `Failed to increment newWorkspaces for ${userId}: ${error?.message || error}`, - ); - } + this.trackMetric( + userId, + { newWorkspaces: 1, activeWorkspaces: 1 }, + "onWorkspaceCreated", + ); } /** @@ -150,13 +127,8 @@ export class UserMetricsService { return; } - const weekStart = this.userMetricsRepository.getWeekStart(); - - await this.userMetricsRepository.incrementMetrics( - userId, - weekStart, - payload, - ); + // Buffer increments instead of immediate DB writes + this.userMetricsBufferService.addToBuffer(userId, payload); } catch (error) { // Log error but do not throw - this should never block the main flow this.logger.error( @@ -189,8 +161,6 @@ export class UserMetricsService { return; } - const weekStart = this.userMetricsRepository.getWeekStart(); - const metricsOperations = operations.map(({ userId, event }) => { let payload: { apisCreated?: number; @@ -227,10 +197,9 @@ export class UserMetricsService { return { userId, payload }; }); - await this.userMetricsRepository.bulkIncrementMetrics( - metricsOperations, - weekStart, - ); + for (const { userId, payload } of metricsOperations) { + this.userMetricsBufferService.addToBuffer(userId, payload); + } } catch (error) { this.logger.error( `Failed to track batch metrics: ${error.message}`, diff --git a/src/modules/workspace/services/userMetricsBuffer.service.ts b/src/modules/workspace/services/userMetricsBuffer.service.ts new file mode 100644 index 000000000..4a0ff2f15 --- /dev/null +++ b/src/modules/workspace/services/userMetricsBuffer.service.ts @@ -0,0 +1,144 @@ +import { + Injectable, + Logger, + OnModuleDestroy, + OnModuleInit, +} from "@nestjs/common"; +import { UserMetricsRepository } from "../repositories/userMetrics.repository"; +import { IncrementMetricsPayload } from "@src/modules/common/models/user-metrics.model"; + +@Injectable() +export class UserMetricsBufferService implements OnModuleInit, OnModuleDestroy { + private readonly logger = new Logger(UserMetricsBufferService.name); + + // buffer: userId -> payload + private buffer: Map = new Map(); + + private intervalId: NodeJS.Timeout | null = null; + + // flush threshold + private readonly FLUSH_THRESHOLD = 500; + + // flush interval (ms) + private readonly FLUSH_INTERVAL = 1000; + + private isFlushing = false; + + constructor(private readonly userMetricsRepository: UserMetricsRepository) {} + + onModuleInit() { + // start periodic flush + this.intervalId = setInterval( + () => + this.flush().catch((e) => + this.logger.error("Periodic flush failed", e), + ), + this.FLUSH_INTERVAL, + ); + } + + onModuleDestroy() { + // clear interval and flush remaining + if (this.intervalId) { + clearInterval(this.intervalId); + this.intervalId = null; + } + + // flush synchronously (best-effort) + this.flush().catch((e) => this.logger.error("Flush on destroy failed", e)); + } + + /** + * Merge and buffer payload for a user. + */ + addToBuffer(userId: string, payload: IncrementMetricsPayload): void { + if (!userId || !payload) return; + + const existing = this.buffer.get(userId); + if (!existing) { + // clone to avoid external mutation + this.buffer.set(userId, { ...payload }); + } else { + // sum numeric fields + if (payload.totalExecutions !== undefined) { + existing.totalExecutions = + (existing.totalExecutions || 0) + payload.totalExecutions; + } + if (payload.apisCreated !== undefined) { + existing.apisCreated = + (existing.apisCreated || 0) + payload.apisCreated; + } + if (payload.collectionsCount !== undefined) { + existing.collectionsCount = + (existing.collectionsCount || 0) + payload.collectionsCount; + } + if (payload.activeWorkspaces !== undefined) { + existing.activeWorkspaces = + (existing.activeWorkspaces || 0) + payload.activeWorkspaces; + } + if (payload.testflowsExecuted !== undefined) { + existing.testflowsExecuted = + (existing.testflowsExecuted || 0) + payload.testflowsExecuted; + } + if (payload.newWorkspaces !== undefined) { + existing.newWorkspaces = + (existing.newWorkspaces || 0) + payload.newWorkspaces; + } + // write back (map stores reference) + this.buffer.set(userId, existing); + } + + if (this.buffer.size >= this.FLUSH_THRESHOLD) { + // trigger async flush but don't await + this.flush().catch((e) => + this.logger.error("Flush on threshold failed", e), + ); + } + + if (this.buffer.size % 50 === 0) { + this.logger.debug(`Buffered metrics for ${this.buffer.size} users`); + } + } + + /** + * Flush buffered metrics to the repository in bulk. + */ + async flush(): Promise { + if (this.isFlushing) return; + + this.isFlushing = true; + + let current: Map; + + try { + if (this.buffer.size === 0) return; + + current = this.buffer; + this.buffer = new Map(); + + const weekStart = this.userMetricsRepository.getWeekStart(); + + const operations = Array.from(current.entries()).map( + ([userId, payload]) => ({ userId, payload }), + ); + + this.logger.log(`Flushing ${operations.length} metric operations`); + + await this.userMetricsRepository.bulkIncrementMetrics( + operations, + weekStart, + ); + + this.logger.log(`Flushed ${operations.length} operations`); + } catch (error) { + this.logger.error("Failed to flush user metrics buffer", error); + + // 🔥 RESTORE BUFFER (IMPORTANT) + for (const [userId, payload] of current.entries()) { + this.addToBuffer(userId, payload); + } + } finally { + this.isFlushing = false; + } + } +} diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts index 075831f3b..ab8843d97 100644 --- a/src/modules/workspace/workspace.module.ts +++ b/src/modules/workspace/workspace.module.ts @@ -27,6 +27,7 @@ import { TestflowRepository } from "./repositories/testflow.repository"; import { SalesEmailRepository } from "./repositories/sales-email.repository"; import { PricingRepository } from "./repositories/pricing.repository"; import { UserMetricsRepository } from "./repositories/userMetrics.repository"; +import { UserMetricsBufferService } from "./services/userMetricsBuffer.service"; // ---- Module import { IdentityModule } from "../identity/identity.module"; @@ -154,6 +155,7 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; WeeklyDigestScheduler, WeeklyDigestService, UserMetricsRepository, + UserMetricsBufferService, UserMetricsService, ], exports: [ @@ -184,6 +186,7 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; PricingService, PricingRepository, UserMetricsRepository, + UserMetricsBufferService, UserMetricsService, ], controllers: [ From 22d88ffa8b87b75e37c09569efee5b64937daee1 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 25 Mar 2026 16:38:58 +0530 Subject: [PATCH 09/21] feat: daily update for user metrics[SPRW-3110] --- .../repositories/userMetrics.repository.ts | 68 +++++++++ .../services/userMetricsBuffer.service.ts | 24 ++++ .../services/weekly-digest.service.ts | 132 ++++++++++++------ 3 files changed, 181 insertions(+), 43 deletions(-) diff --git a/src/modules/workspace/repositories/userMetrics.repository.ts b/src/modules/workspace/repositories/userMetrics.repository.ts index d993bc47b..d0cc119d3 100644 --- a/src/modules/workspace/repositories/userMetrics.repository.ts +++ b/src/modules/workspace/repositories/userMetrics.repository.ts @@ -47,6 +47,14 @@ export class UserMetricsRepository implements OnModuleInit { // Create index on updatedAt for maintenance queries await collection.createIndex({ updatedAt: 1 }, { background: true }); + // Create unique index for daily metrics (user + date) + await this.db + .collection(Collections.USER_METRICS + "_daily") + .createIndex( + { userId: 1, date: 1 }, + { unique: true, background: true }, + ); + this.logger.log("UserMetrics indexes created successfully"); } catch (error) { this.logger.error("Failed to create UserMetrics indexes", error); @@ -194,6 +202,35 @@ export class UserMetricsRepository implements OnModuleInit { .bulkWrite(bulkOps, { ordered: false }); } + /** + * Bulk increment daily execution counts for users. + * Expects operations as array of { userId, totalExecutions } + */ + async bulkIncrementDailyMetrics( + operations: Array<{ userId: string; totalExecutions: number }>, + ): Promise { + if (!operations || operations.length === 0) return null; + + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const bulkOps = operations.map(({ userId, totalExecutions }) => ({ + updateOne: { + filter: { userId, date: today }, + update: { + $inc: { totalExecutions: totalExecutions || 0 }, + $setOnInsert: { userId, date: today }, + }, + upsert: true, + }, + })); + + // Use the daily collection name derived from USER_METRICS + return await this.db + .collection(Collections.USER_METRICS + "_daily") + .bulkWrite(bulkOps as any, { ordered: false }); + } + /** * Merge multiple operations for the same userId by summing their payloads. * Reduces redundant DB operations for high-frequency events. @@ -302,6 +339,37 @@ export class UserMetricsRepository implements OnModuleInit { return metricsMap; } + /** + * Get daily metrics for multiple users between date range. + * Returns raw documents with { userId, date, totalExecutions } + */ + async getDailyMetricsForUsers( + userIds: string[], + from: Date, + to: Date, + ): Promise> { + if (!userIds || userIds.length === 0) return []; + + const results = await this.db + .collection(Collections.USER_METRICS + "_daily") + .find( + { + userId: { $in: userIds }, + date: { $gte: from, $lte: to }, + }, + { + projection: { userId: 1, date: 1, totalExecutions: 1 }, + }, + ) + .toArray(); + + return results.map((r: any) => ({ + userId: r.userId, + date: r.date, + totalExecutions: r.totalExecutions || 0, + })); + } + /** * Get metrics for a single user for a specific week. * diff --git a/src/modules/workspace/services/userMetricsBuffer.service.ts b/src/modules/workspace/services/userMetricsBuffer.service.ts index 4a0ff2f15..19893b2a0 100644 --- a/src/modules/workspace/services/userMetricsBuffer.service.ts +++ b/src/modules/workspace/services/userMetricsBuffer.service.ts @@ -129,6 +129,30 @@ export class UserMetricsBufferService implements OnModuleInit, OnModuleDestroy { weekStart, ); + // Also flush per-user daily execution counts to user_metrics_daily + try { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const dailyIncs = operations + .map(({ userId, payload }) => ({ userId, payload })) + .filter(({ payload }) => (payload.totalExecutions || 0) > 0) + .map(({ userId, payload }) => ({ + userId, + totalExecutions: payload.totalExecutions || 0, + })); + + if (dailyIncs.length > 0) { + // Delegate to repository to perform the bulk daily increments + await this.userMetricsRepository.bulkIncrementDailyMetrics(dailyIncs); + + this.logger.log( + `Flushed daily metrics for ${dailyIncs.length} users`, + ); + } + } catch (dailyErr) { + this.logger.error("Failed to flush daily user metrics", dailyErr); + } this.logger.log(`Flushed ${operations.length} operations`); } catch (error) { this.logger.error("Failed to flush user metrics buffer", error); diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 531df4c06..7decf7627 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -42,7 +42,7 @@ interface UserEmailData { @Injectable() export class WeeklyDigestService { - private static readonly QA_DIGEST_EMAIL = "mayank9@yopmail.com"; + private static readonly QA_DIGEST_EMAIL = "mayank8@yopmail.com"; private static readonly DEFAULT_BATCH_SIZE = 100; private static readonly DEFAULT_EMAIL_CONCURRENCY = 5; @@ -78,13 +78,7 @@ export class WeeklyDigestService { const prevEnd = new Date(start); const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); - // Fetch lightweight global activity graph (only updates collection, not heavy) - const activityGraph = await this.fetchActivityGraph( - start, - end, - prevStart, - prevEnd, - ); + // Note: per-user execution trends are computed per-batch below using daily metrics // Process users in batches using cursor-based pagination let lastCursor: ObjectId | undefined; @@ -124,10 +118,16 @@ export class WeeklyDigestService { usersBatch, ); - // Send emails with controlled concurrency + // Compute per-user execution trends from daily metrics (single batch query) + const activityGraphMap = await this.fetchExecutionTrendsForUsers( + userIds, + end, + ); + + // Send emails with controlled concurrency (per-user graphs) await this.sendEmailsBatch( userEmailDataMap, - activityGraph, + activityGraphMap, start, end, config.emailConcurrency, @@ -172,44 +172,84 @@ export class WeeklyDigestService { * Fetch lightweight activity graph data. * Only queries the updates collection which is lightweight compared to workspace/collection scans. */ - private async fetchActivityGraph( - start: Date, + /** + * Compute per-user execution trends using daily precomputed metrics. + * Returns a Map of userId -> ActivityGraph (totalExecutions, percentChange, graph) + */ + private async fetchExecutionTrendsForUsers( + userIds: string[], end: Date, - prevStart: Date, - prevEnd: Date, - ): Promise { - const [activityData, prevActivityData] = await Promise.all([ - this.updatesRepository.getWeeklyActivity(start, end), - this.updatesRepository.getWeeklyActivity(prevStart, prevEnd), - ]); + ): Promise> { + const map = new Map(); + if (!userIds || userIds.length === 0) return map; + + // Determine the week split points + const currentWeekStart = this.userMetricsRepository.getWeekStart(end); + const prevWeekStart = new Date(currentWeekStart); + prevWeekStart.setDate(currentWeekStart.getDate() - 7); + + // Fetch daily metrics for all users in one query + const rows = await this.userMetricsRepository.getDailyMetricsForUsers( + userIds, + prevWeekStart, + end, + ); - const dailyExecutions = this.formatWeeklyGraph(activityData); - const totalExecutions = dailyExecutions.reduce((a, b) => a + b, 0); + // Group by userId + const grouped = new Map< + string, + Array<{ date: Date; totalExecutions: number }> + >(); + for (const r of rows) { + const arr = grouped.get(r.userId) || []; + arr.push({ date: r.date, totalExecutions: r.totalExecutions }); + grouped.set(r.userId, arr); + } - const prevDailyExecutions = this.formatWeeklyGraph(prevActivityData); - const previousCount = prevDailyExecutions.reduce((a, b) => a + b, 0); + // Build per-user activity graphs + for (const userId of userIds) { + const docs = grouped.get(userId) || []; + + // Accumulate per-day sums for current week (Mon→Sun) + const dailyExecutions = Array(7).fill(0); + let currentTotal = 0; + let prevTotal = 0; + + for (const d of docs) { + const dt = new Date(d.date); + if (dt >= currentWeekStart) { + const idx = (dt.getDay() + 6) % 7; // Mon=0..Sun=6 + dailyExecutions[idx] += d.totalExecutions || 0; + currentTotal += d.totalExecutions || 0; + } else { + prevTotal += d.totalExecutions || 0; + } + } - let percentChange = 0; - if (previousCount === 0 && totalExecutions > 0) { - percentChange = 100; - } else if (previousCount > 0) { - percentChange = Math.round( - ((totalExecutions - previousCount) / previousCount) * 100, - ); - } + let percentChange = 0; + if (prevTotal === 0) { + percentChange = currentTotal > 0 ? 100 : 0; + } else { + percentChange = Math.round( + ((currentTotal - prevTotal) / prevTotal) * 100, + ); + } - const graphHeights = this.normalizeGraphData(dailyExecutions); - const max = Math.max(...graphHeights); - const graph = graphHeights.map((height) => ({ - height, - isMax: height === max, - })); + const graphHeights = this.normalizeGraphData(dailyExecutions); + const max = Math.max(...graphHeights); + const graph = graphHeights.map((height) => ({ + height, + isMax: height === max, + })); + + map.set(userId, { + totalExecutions: currentTotal, + percentChange, + graph, + }); + } - return { - totalExecutions, - percentChange, - graph, - }; + return map; } /** @@ -303,7 +343,7 @@ export class WeeklyDigestService { */ private async sendEmailsBatch( userEmailDataMap: Map, - activityGraph: ActivityGraph, + activityGraphMap: Map, start: Date, end: Date, concurrency: number, @@ -323,6 +363,12 @@ export class WeeklyDigestService { const { user, metrics, collaborationUpdates, pendingActions } = userData; + const activityGraph = activityGraphMap.get(user._id.toString()) || { + totalExecutions: 0, + percentChange: 0, + graph: [], + }; + const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; const mailOptions = { From fb2d8b961021398a1ca36437c4f34c47a8442f18 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 25 Mar 2026 18:24:52 +0530 Subject: [PATCH 10/21] fix: invite flow[SPRW-3110] --- .../repositories/notification.repository.ts | 66 +++++++++++++++++++ .../workspace/services/userMetrics.service.ts | 6 +- .../services/weekly-digest.service.ts | 14 ++-- 3 files changed, 77 insertions(+), 9 deletions(-) diff --git a/src/modules/notifications/repositories/notification.repository.ts b/src/modules/notifications/repositories/notification.repository.ts index d25ce0369..892b1b494 100644 --- a/src/modules/notifications/repositories/notification.repository.ts +++ b/src/modules/notifications/repositories/notification.repository.ts @@ -119,4 +119,70 @@ export class NotificationRepository { "data.inviteStatus": "pending", }); } + + /** + * Fetch pending workspace invite notifications for a list of users within a time range. + * Returns a Map keyed by userId (string) with an array of formatted messages. + */ + async getPendingInvitesForUsers( + userIds: string[], + start: Date, + end: Date, + ): Promise> { + if (!userIds || userIds.length === 0) return new Map(); + + const objectIds = userIds.map((id) => new ObjectId(id)); + + const pipeline = [ + { + $match: { + recipientId: { $in: objectIds }, + type: "WORKSPACE_INVITE", + "data.inviteStatus": "pending", + createdAt: { $gte: start, $lte: end }, + isArchived: false, + }, + }, + { $sort: { createdAt: -1 } }, + { + $group: { + _id: "$recipientId", + invites: { + $push: { + inviterName: "$data.inviterName", + workspaceNames: "$data.workspaceNames", + }, + }, + }, + }, + // Keep only the latest 5 invites per recipient for compactness + { + $project: { + invites: { $slice: ["$invites", 5] }, + }, + }, + ]; + + const results = await this.db + .collection(Collections.NOTIFICATIONS) + .aggregate(pipeline) + .toArray(); + + const map = new Map(); + + for (const row of results) { + const key = row._id.toString(); + const messages: string[] = []; + for (const inv of row.invites || []) { + const inviter = inv?.inviterName || "Someone"; + const workspaceName = Array.isArray(inv?.workspaceNames) + ? inv.workspaceNames[0] + : inv?.workspaceNames || "a workspace"; + messages.push(`${inviter} invited you to ${workspaceName}`); + } + map.set(key, messages); + } + + return map; + } } diff --git a/src/modules/workspace/services/userMetrics.service.ts b/src/modules/workspace/services/userMetrics.service.ts index e52e90711..43ab1df5d 100644 --- a/src/modules/workspace/services/userMetrics.service.ts +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -63,11 +63,7 @@ export class UserMetricsService { * Increments both newWorkspaces and activeWorkspaces for the week. */ async onWorkspaceCreated(userId: string): Promise { - this.trackMetric( - userId, - { newWorkspaces: 1, activeWorkspaces: 1 }, - "onWorkspaceCreated", - ); + this.trackMetric(userId, { newWorkspaces: 1 }, "onWorkspaceCreated"); } /** diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 7decf7627..a803d0e22 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -5,6 +5,7 @@ import { EmailService } from "@src/modules/common/services/email.service"; import { ConfigService } from "@nestjs/config"; import { UpdatesRepository } from "../repositories/updates.repository"; import { UserInvitesRepository } from "@src/modules/identity/repositories/userInvites.repository"; +import { NotificationRepository } from "@src/modules/notifications/repositories/notification.repository"; import { UserMetricsRepository } from "../repositories/userMetrics.repository"; import { ObjectId, WithId } from "mongodb"; import { User } from "@src/modules/common/models/user.model"; @@ -55,6 +56,7 @@ export class WeeklyDigestService { private readonly emailService: EmailService, private readonly configService: ConfigService, private readonly userInvitesRepository: UserInvitesRepository, + private readonly notificationRepository: NotificationRepository, ) {} /** @@ -279,11 +281,15 @@ export class WeeklyDigestService { this.userMetricsRepository.getMetricsForUsers(chunk, weekStart), ); - // Also fetch lightweight updates and invites in parallel - const [metricsMapsArray, updatesMap, invitesMap] = await Promise.all([ + // Also fetch lightweight updates and pending invite notifications in parallel + const [metricsMapsArray, updatesMap, notificationsMap] = await Promise.all([ Promise.all(metricsPromises), this.updatesRepository.getUpdatesForBatch(start, end, userIds), - this.userInvitesRepository.getPendingInvitesForBatch(start, end, emails), + this.notificationRepository.getPendingInvitesForUsers( + userIds, + start, + end, + ), ]); // Merge chunked metrics maps into a single map @@ -304,7 +310,7 @@ export class WeeklyDigestService { const userId = user._id.toString(); const collaborationUpdates = updatesMap.get(userId) || []; - const pendingActions = invitesMap.get(user.email) || []; + const pendingActions = notificationsMap.get(userId) || []; const metricsData: UserMetricsData = mergedMetricsMap.get(userId) ?? { userId, From d5464035f5c785ef59d52df10824c5931b45dd2d Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Wed, 25 Mar 2026 18:44:57 +0530 Subject: [PATCH 11/21] fix: active workspace[SPRW-3110] --- src/modules/workspace/services/userMetrics.service.ts | 1 + src/modules/workspace/services/workspace.service.ts | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/src/modules/workspace/services/userMetrics.service.ts b/src/modules/workspace/services/userMetrics.service.ts index 43ab1df5d..00fb89782 100644 --- a/src/modules/workspace/services/userMetrics.service.ts +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -55,6 +55,7 @@ export class UserMetricsService { * @param userId The user who was active in the workspace */ async onWorkspaceActive(userId: string): Promise { + this.logger.log(`ACTIVE WORKSPACE TRIGGERED: ${userId}`); this.trackMetric(userId, { activeWorkspaces: 1 }, "onWorkspaceActive"); } diff --git a/src/modules/workspace/services/workspace.service.ts b/src/modules/workspace/services/workspace.service.ts index eb9e0e5a2..bebf2baf4 100644 --- a/src/modules/workspace/services/workspace.service.ts +++ b/src/modules/workspace/services/workspace.service.ts @@ -103,6 +103,9 @@ export class WorkspaceService { ); } + // Track access to workspaces as activity (fire-and-forget) + this.userMetricsService.onWorkspaceActive(userId); + const userWorkspaceEntries = user.workspaces || []; const workspaceIdMap = new Map(); @@ -432,6 +435,7 @@ export class WorkspaceService { const workspace = await this.IsWorkspaceAdminOrEditor(id, user._id); const updateNameMessage = `Workspace is renamed from "${workspace.name}" to "${updates.name}"`; const data = await this.workspaceRepository.update(id, updates, user._id); + this.userMetricsService.onWorkspaceActive(user._id.toString()); const team = await this.teamRepository.findTeamByTeamId( new ObjectId(workspace.team.id), ); @@ -502,7 +506,6 @@ export class WorkspaceService { } // Track workspace activity (fire-and-forget) - this.userMetricsService.onWorkspaceActive(user._id.toString()); return data; } From 400bb3e3d2fade735a1292096313d7af1ee51cc0 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 26 Mar 2026 11:33:19 +0530 Subject: [PATCH 12/21] fix: cta link[SPRW-3110] --- src/modules/workspace/services/weekly-digest.service.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index a803d0e22..543bdab8c 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -357,6 +357,8 @@ export class WeeklyDigestService { const transporter = this.emailService.createTransporter(); const senderEmail = this.configService.get("app.senderEmail"); const appUrl = this.configService.get("app.url"); + const marketingBaseUrl = + this.configService.get("MARKETING_BASE_URL") || "https://sparrowapp.dev"; const users = Array.from(userEmailDataMap.values()); @@ -403,7 +405,7 @@ export class WeeklyDigestService { testflowsExecuted: metrics.testflowExecutions, activeWorkspaces: metrics.activeWorkspaces, }, - ctaLink: "https://sparrowapp.dev", + ctaLink: marketingBaseUrl, collaborationUpdates, pendingActions, unsubscribeLink, From 91362447492e785bbcf535d3df922539931685d9 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 26 Mar 2026 14:55:09 +0530 Subject: [PATCH 13/21] fix: set for dev testing[SPRW-3110] --- .../services/weekly-digest.service.ts | 40 ++++++++++++++----- 1 file changed, 29 insertions(+), 11 deletions(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 543bdab8c..69ede3ffe 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -75,10 +75,14 @@ export class WeeklyDigestService { const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; // Time range for the digest (last 1 min for testing, or use getLastWeekRange() for production) + // const end = new Date(); + // const start = new Date(end.getTime() - 1 * 60 * 1000); + // const prevEnd = new Date(start); + // const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); + const end = new Date(); - const start = new Date(end.getTime() - 1 * 60 * 1000); - const prevEnd = new Date(start); - const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); + const start = this.userMetricsRepository.getWeekStart(end); + const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); // Note: per-user execution trends are computed per-batch below using daily metrics @@ -95,7 +99,7 @@ export class WeeklyDigestService { const usersBatch = await this.getUsersBatch( config.userBatchSize, lastCursor, - qaDigestEmail, + // qaDigestEmail, ); if (usersBatch.length === 0) { @@ -187,8 +191,7 @@ export class WeeklyDigestService { // Determine the week split points const currentWeekStart = this.userMetricsRepository.getWeekStart(end); - const prevWeekStart = new Date(currentWeekStart); - prevWeekStart.setDate(currentWeekStart.getDate() - 7); + const { start: prevWeekStart } = this.getPreviousWeekRange(); // Fetch daily metrics for all users in one query const rows = await this.userMetricsRepository.getDailyMetricsForUsers( @@ -267,8 +270,7 @@ export class WeeklyDigestService { users: WithId[], ): Promise> { // Compute weekStart once per batch using the repository helper - const weekStart = this.userMetricsRepository.getWeekStart(); - + const weekStart = this.userMetricsRepository.getWeekStart(end); // If userIds is very large, split into chunks to keep queries manageable const maxChunk = userIds.length > 1000 ? 800 : userIds.length; const chunks: string[][] = []; @@ -360,7 +362,17 @@ export class WeeklyDigestService { const marketingBaseUrl = this.configService.get("MARKETING_BASE_URL") || "https://sparrowapp.dev"; - const users = Array.from(userEmailDataMap.values()); + const env = this.configService.get("APP_ENV")?.toUpperCase(); + const isDev = env === "DEV"; + + let users = Array.from(userEmailDataMap.values()); + + if (isDev) { + const qaUser = users.find( + (u) => u.user.email === WeeklyDigestService.QA_DIGEST_EMAIL, + ); + users = qaUser ? [qaUser] : users.slice(0, 1); + } // Process emails with controlled concurrency using a promise pool await this.processWithConcurrency( @@ -378,10 +390,16 @@ export class WeeklyDigestService { }; const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; + const env = this.configService.get("APP_ENV")?.toUpperCase(); + const isDev = env === "DEV"; + + const recipientEmail = isDev + ? WeeklyDigestService.QA_DIGEST_EMAIL + : user.email; const mailOptions = { from: senderEmail, - to: user.email, + to: recipientEmail, template: "weeklyDigestEmail", subject: "Your Weekly Digest 📊", headers: { @@ -413,7 +431,7 @@ export class WeeklyDigestService { }; await this.emailService.sendEmail(transporter, mailOptions); - this.logger.log(`Weekly digest sent to ${user.email}`); + this.logger.log(`Weekly digest sent to ${recipientEmail}`); } catch (error) { this.logger.error( `Failed to send weekly digest to ${userData.user.email}: ${error.message}`, From 3cd62a400506c79b0562e2fc93f30951b290a580 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 26 Mar 2026 15:01:08 +0530 Subject: [PATCH 14/21] fix: cron set for 10min[SPRW-3110] --- src/modules/workspace/schedulers/weekly-digest.scheduler.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index 3c27c31ed..f68f2760f 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -9,7 +9,7 @@ export class WeeklyDigestScheduler { constructor(private readonly weeklyDigestService: WeeklyDigestService) {} // Disabled until we are sure it works correctly and doesn't cause issues with the database load. We can enable it later once we have confidence in its stability. - @Cron(CronExpression.EVERY_MINUTE, { + @Cron(CronExpression.EVERY_10_MINUTES, { name: "weekly-digest", waitForCompletion: true, }) From 1df5246b4f34df6b21a07a8fa6b783413b5a5bad Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 26 Mar 2026 17:06:56 +0530 Subject: [PATCH 15/21] fix: email changes for testing[SPRW-3110] --- src/modules/workspace/services/weekly-digest.service.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 69ede3ffe..b51cfdf97 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -43,7 +43,7 @@ interface UserEmailData { @Injectable() export class WeeklyDigestService { - private static readonly QA_DIGEST_EMAIL = "mayank8@yopmail.com"; + private static readonly QA_DIGEST_EMAIL = "iamine@yopmail.com"; private static readonly DEFAULT_BATCH_SIZE = 100; private static readonly DEFAULT_EMAIL_CONCURRENCY = 5; From 16231b415d0ae4469b5f16b427c3c20aa96d9761 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Thu, 26 Mar 2026 18:44:35 +0530 Subject: [PATCH 16/21] fix: dev email send logic fixed[SPRW-3110] --- .../services/weekly-digest.service.ts | 165 ++++++++++-------- 1 file changed, 91 insertions(+), 74 deletions(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index b51cfdf97..dd8942923 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -46,6 +46,7 @@ export class WeeklyDigestService { private static readonly QA_DIGEST_EMAIL = "iamine@yopmail.com"; private static readonly DEFAULT_BATCH_SIZE = 100; private static readonly DEFAULT_EMAIL_CONCURRENCY = 5; + private static isJobRunning = false; private readonly logger = new Logger(WeeklyDigestService.name); @@ -65,98 +66,108 @@ export class WeeklyDigestService { * All metrics are computed per-batch using MongoDB aggregation pipelines. */ async processWeeklyDigest(): Promise { - this.logger.log("Processing weekly digest emails..."); + if (WeeklyDigestService.isJobRunning) { + this.logger.warn("Weekly digest already running, skipping..."); + return; + } - const config: BatchConfig = { - userBatchSize: WeeklyDigestService.DEFAULT_BATCH_SIZE, - emailConcurrency: WeeklyDigestService.DEFAULT_EMAIL_CONCURRENCY, - }; + WeeklyDigestService.isJobRunning = true; + try { + this.logger.log("Processing weekly digest emails..."); - const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; + const config: BatchConfig = { + userBatchSize: WeeklyDigestService.DEFAULT_BATCH_SIZE, + emailConcurrency: WeeklyDigestService.DEFAULT_EMAIL_CONCURRENCY, + }; - // Time range for the digest (last 1 min for testing, or use getLastWeekRange() for production) - // const end = new Date(); - // const start = new Date(end.getTime() - 1 * 60 * 1000); - // const prevEnd = new Date(start); - // const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); + const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; - const end = new Date(); - const start = this.userMetricsRepository.getWeekStart(end); - const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); + // Time range for the digest (last 1 min for testing, or use getLastWeekRange() for production) + // const end = new Date(); + // const start = new Date(end.getTime() - 1 * 60 * 1000); + // const prevEnd = new Date(start); + // const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); - // Note: per-user execution trends are computed per-batch below using daily metrics + const end = new Date(); + const start = this.userMetricsRepository.getWeekStart(end); + const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); - // Process users in batches using cursor-based pagination - let lastCursor: ObjectId | undefined; - let totalUsersProcessed = 0; - let batchNumber = 0; + // Note: per-user execution trends are computed per-batch below using daily metrics - while (true) { - batchNumber++; - this.logger.log(`Starting batch ${batchNumber}...`); + // Process users in batches using cursor-based pagination + let lastCursor: ObjectId | undefined; + let totalUsersProcessed = 0; + let batchNumber = 0; - // Fetch the next batch of users - const usersBatch = await this.getUsersBatch( - config.userBatchSize, - lastCursor, - // qaDigestEmail, - ); + while (true) { + batchNumber++; + this.logger.log(`Starting batch ${batchNumber}...`); - if (usersBatch.length === 0) { - this.logger.log(`No more users to process. Ending batch processing.`); - break; - } + // Fetch the next batch of users + const usersBatch = await this.getUsersBatch( + config.userBatchSize, + lastCursor, + // qaDigestEmail, + ); - this.logger.log( - `Batch ${batchNumber}: Processing ${usersBatch.length} users...`, - ); + if (usersBatch.length === 0) { + this.logger.log(`No more users to process. Ending batch processing.`); + break; + } - // Extract user IDs and emails for batch queries - const userIds = usersBatch.map((u) => u._id.toString()); - const emails = usersBatch.map((u) => u.email); + this.logger.log( + `Batch ${batchNumber}: Processing ${usersBatch.length} users...`, + ); - // Fetch per-user data using precomputed user metrics (no aggregation) - const userEmailDataMap = await this.getMetricsForUserBatch( - start, - end, - userIds, - emails, - usersBatch, - ); + // Extract user IDs and emails for batch queries + const userIds = usersBatch.map((u) => u._id.toString()); + const emails = usersBatch.map((u) => u.email); + + // Fetch per-user data using precomputed user metrics (no aggregation) + const userEmailDataMap = await this.getMetricsForUserBatch( + start, + end, + userIds, + emails, + usersBatch, + ); - // Compute per-user execution trends from daily metrics (single batch query) - const activityGraphMap = await this.fetchExecutionTrendsForUsers( - userIds, - end, - ); + // Compute per-user execution trends from daily metrics (single batch query) + const activityGraphMap = await this.fetchExecutionTrendsForUsers( + userIds, + end, + ); - // Send emails with controlled concurrency (per-user graphs) - await this.sendEmailsBatch( - userEmailDataMap, - activityGraphMap, - start, - end, - config.emailConcurrency, - ); + // Send emails with controlled concurrency (per-user graphs) + await this.sendEmailsBatch( + userEmailDataMap, + activityGraphMap, + start, + end, + config.emailConcurrency, + ); - totalUsersProcessed += usersBatch.length; - this.logger.log( - `Batch ${batchNumber} complete. Total users processed: ${totalUsersProcessed}`, - ); + totalUsersProcessed += usersBatch.length; + this.logger.log( + `Batch ${batchNumber} complete. Total users processed: ${totalUsersProcessed}`, + ); - // Update cursor for next batch - lastCursor = usersBatch[usersBatch.length - 1]._id; + // Update cursor for next batch + lastCursor = usersBatch[usersBatch.length - 1]._id; - // If we got fewer users than the batch size, we've reached the end - if (usersBatch.length < config.userBatchSize) { - this.logger.log(`Reached end of users. Stopping batch processing.`); - break; + // If we got fewer users than the batch size, we've reached the end + if (usersBatch.length < config.userBatchSize) { + this.logger.log(`Reached end of users. Stopping batch processing.`); + break; + } } - } - this.logger.log( - `Weekly digest processing complete. Total users processed: ${totalUsersProcessed}`, - ); + this.logger.log( + `Weekly digest processing complete. Total users processed: ${totalUsersProcessed}`, + ); + } finally { + WeeklyDigestService.isJobRunning = false; + } } /** @@ -371,7 +382,13 @@ export class WeeklyDigestService { const qaUser = users.find( (u) => u.user.email === WeeklyDigestService.QA_DIGEST_EMAIL, ); - users = qaUser ? [qaUser] : users.slice(0, 1); + + if (!qaUser) { + this.logger.warn("QA user not found, skipping email in DEV"); + return; // stop execution + } + + users = [qaUser]; } // Process emails with controlled concurrency using a promise pool From e373b215434f4f5cf1e59436701d8713ff706057 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 12:03:25 +0530 Subject: [PATCH 17/21] fix: pending action for admin user[SPRW-3110] --- .../repositories/notification.repository.ts | 24 +++++++++++++++---- 1 file changed, 20 insertions(+), 4 deletions(-) diff --git a/src/modules/notifications/repositories/notification.repository.ts b/src/modules/notifications/repositories/notification.repository.ts index 892b1b494..b58c3a558 100644 --- a/src/modules/notifications/repositories/notification.repository.ts +++ b/src/modules/notifications/repositories/notification.repository.ts @@ -151,6 +151,8 @@ export class NotificationRepository { $push: { inviterName: "$data.inviterName", workspaceNames: "$data.workspaceNames", + role: "$data.role", + teamName: "$data.teamName", }, }, }, @@ -175,10 +177,24 @@ export class NotificationRepository { const messages: string[] = []; for (const inv of row.invites || []) { const inviter = inv?.inviterName || "Someone"; - const workspaceName = Array.isArray(inv?.workspaceNames) - ? inv.workspaceNames[0] - : inv?.workspaceNames || "a workspace"; - messages.push(`${inviter} invited you to ${workspaceName}`); + + if (inv?.role === "admin") { + const teamName = inv?.teamName || "team"; + messages.push(`${inviter} invited you as admin to ${teamName}`); + } else { + const workspaceNames = Array.isArray(inv?.workspaceNames) + ? inv.workspaceNames + : inv?.workspaceNames + ? [inv.workspaceNames] + : []; + + const workspaceText = + workspaceNames.length > 0 + ? workspaceNames.join(", ") + : "a workspace"; + + messages.push(`${inviter} invited you to ${workspaceText}`); + } } map.set(key, messages); } From 95f82f5dc1fb2f1f23799b074b5e3c4c3e2f40df Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Fri, 27 Mar 2026 17:57:13 +0530 Subject: [PATCH 18/21] fix: modified for prod[SPRW-3110] --- .../schedulers/weekly-digest.scheduler.ts | 18 +++++-- .../services/weekly-digest.service.ts | 50 +++++++++++-------- 2 files changed, 45 insertions(+), 23 deletions(-) diff --git a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index f68f2760f..a164eaff3 100644 --- a/src/modules/workspace/schedulers/weekly-digest.scheduler.ts +++ b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts @@ -1,19 +1,31 @@ import { Injectable, Logger } from "@nestjs/common"; import { Cron, CronExpression } from "@nestjs/schedule"; import { WeeklyDigestService } from "../services/weekly-digest.service"; +import { ConfigService } from "@nestjs/config"; @Injectable() export class WeeklyDigestScheduler { private readonly logger = new Logger(WeeklyDigestScheduler.name); - constructor(private readonly weeklyDigestService: WeeklyDigestService) {} + constructor( + private readonly weeklyDigestService: WeeklyDigestService, + private readonly configService: ConfigService, + ) {} - // Disabled until we are sure it works correctly and doesn't cause issues with the database load. We can enable it later once we have confidence in its stability. - @Cron(CronExpression.EVERY_10_MINUTES, { + // Runs every Monday at 8:00 AM + @Cron("0 8 * * 1", { name: "weekly-digest", + timeZone: "Asia/Kolkata", waitForCompletion: true, }) async handleWeeklyDigest() { + const env = this.configService.get("APP_ENV")?.toUpperCase(); + + if (env !== "PROD") { + this.logger.log(`Skipping Weekly Digest Job in ${env} environment`); + return; + } + this.logger.log("Starting Weekly Digest Job..."); try { diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index dd8942923..ce989e8e6 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -87,9 +87,10 @@ export class WeeklyDigestService { // const start = new Date(end.getTime() - 1 * 60 * 1000); // const prevEnd = new Date(start); // const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); + // const end = new Date(); + // const start = this.userMetricsRepository.getWeekStart(end); - const end = new Date(); - const start = this.userMetricsRepository.getWeekStart(end); + const { start, end } = this.getLastWeekRange(); const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); // Note: per-user execution trends are computed per-batch below using daily metrics @@ -373,23 +374,27 @@ export class WeeklyDigestService { const marketingBaseUrl = this.configService.get("MARKETING_BASE_URL") || "https://sparrowapp.dev"; - const env = this.configService.get("APP_ENV")?.toUpperCase(); - const isDev = env === "DEV"; + //Dev - send all digests to QA email - let users = Array.from(userEmailDataMap.values()); + // const env = this.configService.get("APP_ENV")?.toUpperCase(); + // const isDev = env === "DEV"; - if (isDev) { - const qaUser = users.find( - (u) => u.user.email === WeeklyDigestService.QA_DIGEST_EMAIL, - ); + // let users = Array.from(userEmailDataMap.values()); - if (!qaUser) { - this.logger.warn("QA user not found, skipping email in DEV"); - return; // stop execution - } + // if (isDev) { + // const qaUser = users.find( + // (u) => u.user.email === WeeklyDigestService.QA_DIGEST_EMAIL, + // ); - users = [qaUser]; - } + // if (!qaUser) { + // this.logger.warn("QA user not found, skipping email in DEV"); + // return; // stop execution + // } + + // users = [qaUser]; + // } + + const users = Array.from(userEmailDataMap.values()); // Process emails with controlled concurrency using a promise pool await this.processWithConcurrency( @@ -407,12 +412,17 @@ export class WeeklyDigestService { }; const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; - const env = this.configService.get("APP_ENV")?.toUpperCase(); - const isDev = env === "DEV"; - const recipientEmail = isDev - ? WeeklyDigestService.QA_DIGEST_EMAIL - : user.email; + // const env = this.configService.get("APP_ENV")?.toUpperCase(); + // const isDev = env === "DEV"; + + // In DEV, override recipient email to QA email to avoid sending real emails + + // const recipientEmail = isDev + // ? WeeklyDigestService.QA_DIGEST_EMAIL + // : user.email; + + const recipientEmail = user.email; const mailOptions = { from: senderEmail, From cba143c36f2cf62f6d7cf48cb646957f1e914fd2 Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 14:56:06 +0530 Subject: [PATCH 19/21] fix: comments removed[SPRW-3110] --- .../services/weekly-digest.service.ts | 37 ------------------- 1 file changed, 37 deletions(-) diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index ce989e8e6..da4ba6a1f 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -82,14 +82,6 @@ export class WeeklyDigestService { const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; - // Time range for the digest (last 1 min for testing, or use getLastWeekRange() for production) - // const end = new Date(); - // const start = new Date(end.getTime() - 1 * 60 * 1000); - // const prevEnd = new Date(start); - // const prevStart = new Date(prevEnd.getTime() - 1 * 60 * 1000); - // const end = new Date(); - // const start = this.userMetricsRepository.getWeekStart(end); - const { start, end } = this.getLastWeekRange(); const { start: prevStart, end: prevEnd } = this.getPreviousWeekRange(); @@ -374,26 +366,6 @@ export class WeeklyDigestService { const marketingBaseUrl = this.configService.get("MARKETING_BASE_URL") || "https://sparrowapp.dev"; - //Dev - send all digests to QA email - - // const env = this.configService.get("APP_ENV")?.toUpperCase(); - // const isDev = env === "DEV"; - - // let users = Array.from(userEmailDataMap.values()); - - // if (isDev) { - // const qaUser = users.find( - // (u) => u.user.email === WeeklyDigestService.QA_DIGEST_EMAIL, - // ); - - // if (!qaUser) { - // this.logger.warn("QA user not found, skipping email in DEV"); - // return; // stop execution - // } - - // users = [qaUser]; - // } - const users = Array.from(userEmailDataMap.values()); // Process emails with controlled concurrency using a promise pool @@ -413,15 +385,6 @@ export class WeeklyDigestService { const unsubscribeLink = `${appUrl}/api/user/unsubscribe-weekly-digest?userId=${user._id}`; - // const env = this.configService.get("APP_ENV")?.toUpperCase(); - // const isDev = env === "DEV"; - - // In DEV, override recipient email to QA email to avoid sending real emails - - // const recipientEmail = isDev - // ? WeeklyDigestService.QA_DIGEST_EMAIL - // : user.email; - const recipientEmail = user.email; const mailOptions = { From 64993bcbf5decafee4d27e19e8cf19513570c0fb Mon Sep 17 00:00:00 2001 From: Mayank Jha Date: Mon, 30 Mar 2026 15:34:44 +0530 Subject: [PATCH 20/21] fix: version bump [SPRW-1234] --- .github/workflows/staging.yml | 2 +- package.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/staging.yml b/.github/workflows/staging.yml index ac1dfb7e6..72df1f7fb 100644 --- a/.github/workflows/staging.yml +++ b/.github/workflows/staging.yml @@ -2,7 +2,7 @@ name: Staging on: push: branches: - - release/2.37.0 + - release/2.39.0 jobs: build: diff --git a/package.json b/package.json index 2d2ff47c7..236ee9c12 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "project-sparrow-api", - "version": "2.37.0", + "version": "2.39.0", "description": "Backend APIs for Project Sparrow.", "author": "techdome", "license": "", From bf07673a78175197032dc01688c864c530b38de4 Mon Sep 17 00:00:00 2001 From: LordNayan Date: Tue, 31 Mar 2026 12:02:19 +0530 Subject: [PATCH 21/21] feat: enable cors for dev --- src/main.ts | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/main.ts b/src/main.ts index eabe29ac6..464cfdbca 100644 --- a/src/main.ts +++ b/src/main.ts @@ -74,7 +74,13 @@ const { PORT } = process.env; SwaggerModule.setup(SWAGGER_API_ROOT, app, document); // Enable Cross-Origin Resource Sharing (CORS) - app.enableCors(); + if (process.env.APP_ENV === "DEV") { + app.enableCors({ + origin: "*", + }); + } else { + app.enableCors(); + } // Register additional Fastify plugins app.register(headers);