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": "", 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); 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..13f1bae42 --- /dev/null +++ b/src/modules/common/models/user-metrics.model.ts @@ -0,0 +1,105 @@ +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 new workspaces created by the user this week. + */ + @IsNumber() + @IsOptional() + newWorkspaces?: 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; + newWorkspaces?: number; + testflowsExecuted?: number; +} + +/** + * Metrics data returned from the repository. + */ +export interface UserMetricsData { + userId: string; + weekStart: Date; + totalExecutions: number; + apisCreated: number; + collectionsCount: number; + activeWorkspaces: number; + newWorkspaces: number; + testflowsExecuted: number; + updatedAt: Date; +} 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/notifications/repositories/notification.repository.ts b/src/modules/notifications/repositories/notification.repository.ts index d25ce0369..b58c3a558 100644 --- a/src/modules/notifications/repositories/notification.repository.ts +++ b/src/modules/notifications/repositories/notification.repository.ts @@ -119,4 +119,86 @@ 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", + role: "$data.role", + teamName: "$data.teamName", + }, + }, + }, + }, + // 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"; + + 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); + } + + return map; + } } diff --git a/src/modules/workspace/repositories/userMetrics.repository.ts b/src/modules/workspace/repositories/userMetrics.repository.ts new file mode 100644 index 000000000..d0cc119d3 --- /dev/null +++ b/src/modules/workspace/repositories/userMetrics.repository.ts @@ -0,0 +1,488 @@ +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 }); + + // 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); + } + } + + /** + * 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; + } + if (payload.newWorkspaces !== undefined) { + incPayload.newWorkspaces = payload.newWorkspaces; + } + + // 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, + }, + }, + { 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; + } + if (payload.newWorkspaces !== undefined) { + incPayload.newWorkspaces = payload.newWorkspaces; + } + + return { + updateOne: { + filter: { userId, weekStart }, + update: { + $inc: incPayload, + $set: { updatedAt: new Date() }, + $setOnInsert: { + userId, + weekStart, + }, + }, + upsert: true, + }, + }; + }); + + return await this.db + .collection(Collections.USER_METRICS) + .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. + * + * @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; + } + if (payload.newWorkspaces !== undefined) { + existing.newWorkspaces = + (existing.newWorkspaces || 0) + payload.newWorkspaces; + } + } + } + + 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, + newWorkspaces: 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, + newWorkspaces: result.newWorkspaces || 0, + testflowsExecuted: result.testflowsExecuted || 0, + updatedAt: result.updatedAt || new Date(), + }); + } + + 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. + * + * @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, + newWorkspaces: result.newWorkspaces || 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.newWorkspaces !== undefined) { + setPayload.newWorkspaces = metrics.newWorkspaces; + } + 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/schedulers/weekly-digest.scheduler.ts b/src/modules/workspace/schedulers/weekly-digest.scheduler.ts index fc81a6194..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_30_MINUTES, { - // name: "weekly-digest", - // waitForCompletion: true, - // }) + // 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/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/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; } 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..00fb89782 --- /dev/null +++ b/src/modules/workspace/services/userMetrics.service.ts @@ -0,0 +1,207 @@ +import { Injectable, Logger } from "@nestjs/common"; +import { UserMetricsRepository } from "../repositories/userMetrics.repository"; +import { UserMetricsBufferService } from "./userMetricsBuffer.service"; + +/** + * 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, + private readonly userMetricsBufferService: UserMetricsBufferService, + ) {} + + /** + * 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.logger.log(`Metrics update: API created for ${userId}`); + 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.logger.log(`Metrics update: Collection created for ${userId}`); + 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.logger.log(`ACTIVE WORKSPACE TRIGGERED: ${userId}`); + this.trackMetric(userId, { activeWorkspaces: 1 }, "onWorkspaceActive"); + } + + /** + * Track when a user creates a workspace. + * Increments both newWorkspaces and activeWorkspaces for the week. + */ + async onWorkspaceCreated(userId: string): Promise { + this.trackMetric(userId, { newWorkspaces: 1 }, "onWorkspaceCreated"); + } + + /** + * 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.logger.log(`Metrics update: Execution activity for ${userId}`); + 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; + newWorkspaces?: 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; + newWorkspaces?: number; + totalExecutions?: number; + }, + eventName: string, + ): Promise { + try { + if (!userId) { + return; + } + + // 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( + `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" + | "workspaceCreated" + | "executionActivity"; + }>, + ): Promise { + try { + if (operations.length === 0) { + return; + } + + const metricsOperations = operations.map(({ userId, event }) => { + let payload: { + apisCreated?: number; + testflowsExecuted?: number; + collectionsCount?: number; + activeWorkspaces?: number; + newWorkspaces?: 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 "workspaceCreated": + payload = { newWorkspaces: 1, activeWorkspaces: 1 }; + break; + case "executionActivity": + payload = { totalExecutions: 1 }; + break; + default: + payload = {}; + } + + return { userId, payload }; + }); + + for (const { userId, payload } of metricsOperations) { + this.userMetricsBufferService.addToBuffer(userId, payload); + } + } catch (error) { + this.logger.error( + `Failed to track batch metrics: ${error.message}`, + error.stack, + ); + } + } +} diff --git a/src/modules/workspace/services/userMetricsBuffer.service.ts b/src/modules/workspace/services/userMetricsBuffer.service.ts new file mode 100644 index 000000000..19893b2a0 --- /dev/null +++ b/src/modules/workspace/services/userMetricsBuffer.service.ts @@ -0,0 +1,168 @@ +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, + ); + + // 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); + + // 🔥 RESTORE BUFFER (IMPORTANT) + for (const [userId, payload] of current.entries()) { + this.addToBuffer(userId, payload); + } + } finally { + this.isFlushing = false; + } + } +} diff --git a/src/modules/workspace/services/weekly-digest.service.ts b/src/modules/workspace/services/weekly-digest.service.ts index 432e303dd..da4ba6a1f 100644 --- a/src/modules/workspace/services/weekly-digest.service.ts +++ b/src/modules/workspace/services/weekly-digest.service.ts @@ -1,14 +1,15 @@ 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 { 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"; +import { UserMetricsData } from "@src/modules/common/models/user-metrics.model"; /** Configuration for batch processing and concurrency */ interface BatchConfig { @@ -19,10 +20,10 @@ interface BatchConfig { /** Per-user metrics computed via batch aggregation */ interface UserMetrics { activeWorkspaces: number; - newWorkspaces: number; collectionsCount: number; apisCount: number; testflowExecutions: number; + newWorkspaces: number; } /** Activity graph data for the digest */ @@ -42,21 +43,21 @@ interface UserEmailData { @Injectable() export class WeeklyDigestService { - private static readonly QA_DIGEST_EMAIL = ""; + 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); 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, + private readonly notificationRepository: NotificationRepository, ) {} /** @@ -65,94 +66,101 @@ 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 30 mins for testing, or use getLastWeekRange() for production) - const end = new Date(); - const start = new Date(end.getTime() - 30 * 60 * 1000); - const prevEnd = new Date(start); - const prevStart = new Date(prevEnd.getTime() - 30 * 60 * 1000); + const qaDigestEmail = WeeklyDigestService.QA_DIGEST_EMAIL; - // Fetch lightweight global activity graph (only updates collection, not heavy) - const activityGraph = await this.fetchActivityGraph( - start, - end, - prevStart, - prevEnd, - ); + const { start, end } = this.getLastWeekRange(); + 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 and metrics in bulk using 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); - // Send emails with controlled concurrency - await this.sendEmailsBatch( - userEmailDataMap, - activityGraph, - start, - end, - config.emailConcurrency, - ); + // Fetch per-user data using precomputed user metrics (no aggregation) + const userEmailDataMap = await this.getMetricsForUserBatch( + start, + end, + userIds, + emails, + usersBatch, + ); - totalUsersProcessed += usersBatch.length; - this.logger.log( - `Batch ${batchNumber} complete. Total users processed: ${totalUsersProcessed}`, - ); + // Compute per-user execution trends from daily metrics (single batch query) + const activityGraphMap = await this.fetchExecutionTrendsForUsers( + userIds, + end, + ); - // Update cursor for next batch - lastCursor = usersBatch[usersBatch.length - 1]._id; + // 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}`, + ); - // 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; + // 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; + } } - } - 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; + } } /** @@ -174,49 +182,88 @@ 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 { start: prevWeekStart } = this.getPreviousWeekRange(); + + // 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; } /** * 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 +273,69 @@ 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(end); + // 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, - ), - ]); + // 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 pending invite notifications in parallel + const [metricsMapsArray, updatesMap, notificationsMap] = await Promise.all([ + Promise.all(metricsPromises), + this.updatesRepository.getUpdatesForBatch(start, end, userIds), + this.notificationRepository.getPendingInvitesForUsers( + userIds, + start, + end, + ), + ]); + + // 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})`, + ); - // Build the email data map for each user 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) || []; + const pendingActions = notificationsMap.get(userId) || []; - // Get workspace metrics for this user - const workspaceMetrics = workspaceMetricsMap.get(userId) || { + const metricsData: UserMetricsData = mergedMetricsMap.get(userId) ?? { + userId, + weekStart, + totalExecutions: 0, + apisCreated: 0, + collectionsCount: 0, activeWorkspaces: 0, newWorkspaces: 0, - collectionsCount: 0, - apisCount: 0, + testflowsExecuted: 0, + updatedAt: new Date(), }; - // Get testflow metrics for this user - const testflowExecutions = testflowMetricsMap.get(userId) || 0; - const metrics: UserMetrics = { - activeWorkspaces: workspaceMetrics.activeWorkspaces, - newWorkspaces: workspaceMetrics.newWorkspaces, - collectionsCount: workspaceMetrics.collectionsCount, - apisCount: workspaceMetrics.apisCount, - testflowExecutions, + activeWorkspaces: metricsData.activeWorkspaces || 0, + newWorkspaces: metricsData.newWorkspaces || 0, + collectionsCount: metricsData.collectionsCount || 0, + apisCount: metricsData.apisCreated || 0, + testflowExecutions: metricsData.testflowsExecuted || 0, }; userEmailDataMap.set(userId, { @@ -302,7 +355,7 @@ export class WeeklyDigestService { */ private async sendEmailsBatch( userEmailDataMap: Map, - activityGraph: ActivityGraph, + activityGraphMap: Map, start: Date, end: Date, concurrency: number, @@ -310,6 +363,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()); @@ -322,11 +377,19 @@ 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 recipientEmail = user.email; + const mailOptions = { from: senderEmail, - to: user.email, + to: recipientEmail, template: "weeklyDigestEmail", subject: "Your Weekly Digest 📊", headers: { @@ -350,7 +413,7 @@ export class WeeklyDigestService { testflowsExecuted: metrics.testflowExecutions, activeWorkspaces: metrics.activeWorkspaces, }, - ctaLink: "https://sparrowapp.dev", + ctaLink: marketingBaseUrl, collaborationUpdates, pendingActions, unsubscribeLink, @@ -358,7 +421,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}`, diff --git a/src/modules/workspace/services/workspace.service.ts b/src/modules/workspace/services/workspace.service.ts index 52137f620..bebf2baf4 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> { @@ -101,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(); @@ -405,6 +410,9 @@ export class WorkspaceService { ); } + // Track workspace creation (fire-and-forget) + this.userMetricsService.onWorkspaceCreated(user._id.toString()); + return response; } @@ -427,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), ); @@ -495,6 +504,8 @@ export class WorkspaceService { }), }); } + + // Track workspace activity (fire-and-forget) return data; } diff --git a/src/modules/workspace/workspace.module.ts b/src/modules/workspace/workspace.module.ts index 69c781444..ab8843d97 100644 --- a/src/modules/workspace/workspace.module.ts +++ b/src/modules/workspace/workspace.module.ts @@ -26,6 +26,8 @@ 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"; +import { UserMetricsBufferService } from "./services/userMetricsBuffer.service"; // ---- Module import { IdentityModule } from "../identity/identity.module"; @@ -59,6 +61,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 { @@ -151,6 +154,9 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; AiConsumptionScheduler, WeeklyDigestScheduler, WeeklyDigestService, + UserMetricsRepository, + UserMetricsBufferService, + UserMetricsService, ], exports: [ CollectionService, @@ -179,6 +185,9 @@ import { WeeklyDigestScheduler } from "./schedulers/weekly-digest.scheduler"; SalesEmailRepository, PricingService, PricingRepository, + UserMetricsRepository, + UserMetricsBufferService, + UserMetricsService, ], controllers: [ WorkSpaceController,