diff --git a/contributing.md b/contributing.md index 8c55c01149..e015804c8d 100644 --- a/contributing.md +++ b/contributing.md @@ -27,6 +27,8 @@ To get an overview of each microservice, read the respective microservice README - [meta-data](/src/meta-data/README.md) - [Predict](/src/predict/README.md) - [View](/src/view/README.md) + + Here are some resources to help you get started with open source contributions: - [How to Contribute to Open Source](https://opensource.guide/how-to-contribute/) diff --git a/k8s/auth-service/values-prod.yaml b/k8s/auth-service/values-prod.yaml index 708db5b069..0ecaff6f03 100644 --- a/k8s/auth-service/values-prod.yaml +++ b/k8s/auth-service/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-auth-api - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/auth-service/values-stage.yaml b/k8s/auth-service/values-stage.yaml index 9f22fb3cf5..0e784df33a 100644 --- a/k8s/auth-service/values-stage.yaml +++ b/k8s/auth-service/values-stage.yaml @@ -6,7 +6,7 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-auth-api - tag: stage-a9e01103-1734211833 + tag: stage-5d451842-1736194286 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-prod.yaml b/k8s/device-registry/values-prod.yaml index 7dfc2317a0..56fbb0a9c9 100644 --- a/k8s/device-registry/values-prod.yaml +++ b/k8s/device-registry/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-device-registry-api - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/device-registry/values-stage.yaml b/k8s/device-registry/values-stage.yaml index af9412c43b..9db1b08f46 100644 --- a/k8s/device-registry/values-stage.yaml +++ b/k8s/device-registry/values-stage.yaml @@ -6,7 +6,7 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-device-registry-api - tag: stage-a3fd1cf0-1734679838 + tag: stage-4ca1c803-1736183154 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/exceedance/values-prod-airqo.yaml b/k8s/exceedance/values-prod-airqo.yaml index 3e42145920..67aaffe2bf 100644 --- a/k8s/exceedance/values-prod-airqo.yaml +++ b/k8s/exceedance/values-prod-airqo.yaml @@ -4,6 +4,6 @@ app: configmap: env-exceedance-production image: repository: eu.gcr.io/airqo-250220/airqo-exceedance-job - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' diff --git a/k8s/exceedance/values-prod-kcca.yaml b/k8s/exceedance/values-prod-kcca.yaml index b6fa474494..0fc9befa6e 100644 --- a/k8s/exceedance/values-prod-kcca.yaml +++ b/k8s/exceedance/values-prod-kcca.yaml @@ -4,6 +4,6 @@ app: configmap: env-exceedance-production image: repository: eu.gcr.io/airqo-250220/kcca-exceedance-job - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' diff --git a/k8s/incentives/values-prod.yaml b/k8s/incentives/values-prod.yaml index 249470f336..490edb9fd8 100644 --- a/k8s/incentives/values-prod.yaml +++ b/k8s/incentives/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-incentives-api - tag: prod-8ec03422-1731102123 + tag: prod-fbf1a3e5-1735852910 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/incentives/values-stage.yaml b/k8s/incentives/values-stage.yaml index cc0eb4ab99..d279b64810 100644 --- a/k8s/incentives/values-stage.yaml +++ b/k8s/incentives/values-stage.yaml @@ -6,13 +6,12 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-incentives-api - tag: stage-96d94ce4-1715349509 + tag: stage-6ffb3fe6-1735572850 nameOverride: '' fullnameOverride: '' podAnnotations: {} resources: limits: - cpu: 1000m memory: 500Mi requests: cpu: 100m diff --git a/k8s/insights/values-prod.yaml b/k8s/insights/values-prod.yaml index 090bd6c6e0..4bc3b517cb 100644 --- a/k8s/insights/values-prod.yaml +++ b/k8s/insights/values-prod.yaml @@ -6,7 +6,7 @@ images: api: eu.gcr.io/airqo-250220/airqo-insights-api celery: eu.gcr.io/airqo-250220/airqo-insights-celery celeryWorker: eu.gcr.io/airqo-250220/airqo-insights-celery-worker - tag: prod-015fde10-1730310056 + tag: prod-b1c7ea69-1735899837 api: name: airqo-insights-api label: insights-api diff --git a/k8s/insights/values-stage.yaml b/k8s/insights/values-stage.yaml index 441bce2842..7b943588b8 100644 --- a/k8s/insights/values-stage.yaml +++ b/k8s/insights/values-stage.yaml @@ -5,7 +5,7 @@ images: repositories: api: eu.gcr.io/airqo-250220/airqo-stage-insights-api celery: eu.gcr.io/airqo-250220/airqo-stage-insights-celery - tag: stage-38af6de7-1730309744 + tag: stage-6ffb3fe6-1735572850 api: name: airqo-stage-insights-api label: sta-alytics-api @@ -14,7 +14,6 @@ api: podAnnotations: {} resources: limits: - cpu: 100m memory: 600Mi requests: cpu: 10m diff --git a/k8s/kafka/topics/kafka-topics.yaml b/k8s/kafka/topics/kafka-topics.yaml index bd594f7b29..c46dbd67a7 100644 --- a/k8s/kafka/topics/kafka-topics.yaml +++ b/k8s/kafka/topics/kafka-topics.yaml @@ -307,3 +307,17 @@ spec: replicas: 2 config: retention.ms: 18000000 + +--- +apiVersion: kafka.strimzi.io/v1beta2 +kind: KafkaTopic +metadata: + name: airqo.forecasts + namespace: message-broker + labels: + strimzi.io/cluster: kafka-cluster +spec: + partitions: 2 + replicas: 2 + config: + retention.ms: 18000000 diff --git a/k8s/meta-data/values-prod.yaml b/k8s/meta-data/values-prod.yaml index fd66986e3a..44842dd224 100644 --- a/k8s/meta-data/values-prod.yaml +++ b/k8s/meta-data/values-prod.yaml @@ -8,7 +8,7 @@ images: repositories: api: eu.gcr.io/airqo-250220/airqo-meta-data-api sitesConsumer: eu.gcr.io/airqo-250220/airqo-meta-data-sites-consumer - tag: prod-5244fe67-1730977223 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/meta-data/values-stage.yaml b/k8s/meta-data/values-stage.yaml index f9143958cb..b091a1d930 100644 --- a/k8s/meta-data/values-stage.yaml +++ b/k8s/meta-data/values-stage.yaml @@ -8,13 +8,12 @@ images: repositories: api: eu.gcr.io/airqo-250220/airqo-stage-meta-data-api sitesConsumer: eu.gcr.io/airqo-250220/airqo-stage-meta-data-sites-consumer - tag: stage-38af6de7-1730309744 + tag: stage-6ffb3fe6-1735572850 nameOverride: '' fullnameOverride: '' podAnnotations: {} resources: limits: - cpu: 400m memory: 700Mi requests: cpu: 50m diff --git a/k8s/predict/values-prod.yaml b/k8s/predict/values-prod.yaml index 5059f4a050..0189dc34a5 100644 --- a/k8s/predict/values-prod.yaml +++ b/k8s/predict/values-prod.yaml @@ -7,7 +7,7 @@ images: predictJob: eu.gcr.io/airqo-250220/airqo-predict-job trainJob: eu.gcr.io/airqo-250220/airqo-train-job predictPlaces: eu.gcr.io/airqo-250220/airqo-predict-places-air-quality - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 api: name: airqo-prediction-api label: prediction-api diff --git a/k8s/predict/values-stage.yaml b/k8s/predict/values-stage.yaml index 11dd2c8569..dfb2ec36e7 100644 --- a/k8s/predict/values-stage.yaml +++ b/k8s/predict/values-stage.yaml @@ -16,7 +16,6 @@ api: podAnnotations: {} resources: limits: - cpu: 50m memory: 200Mi requests: cpu: 5m diff --git a/k8s/spatial/values-prod.yaml b/k8s/spatial/values-prod.yaml index 213e46a753..ed2c506949 100644 --- a/k8s/spatial/values-prod.yaml +++ b/k8s/spatial/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-spatial-api - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/spatial/values-stage.yaml b/k8s/spatial/values-stage.yaml index b50018fa95..359a8ba35c 100644 --- a/k8s/spatial/values-stage.yaml +++ b/k8s/spatial/values-stage.yaml @@ -6,13 +6,12 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-spatial-api - tag: stage-1595bb07-1734517712 + tag: stage-6ffb3fe6-1735572850 nameOverride: '' fullnameOverride: '' podAnnotations: {} resources: limits: - cpu: 100m memory: 400Mi requests: cpu: 10m diff --git a/k8s/view/values-prod.yaml b/k8s/view/values-prod.yaml index 52344c6432..674a6627bc 100644 --- a/k8s/view/values-prod.yaml +++ b/k8s/view/values-prod.yaml @@ -5,7 +5,7 @@ images: repositories: api: eu.gcr.io/airqo-250220/airqo-view-api messageBroker: eu.gcr.io/airqo-250220/airqo-view-message-broker - tag: prod-1f061bdf-1731360102 + tag: prod-c1d1fce6-1735895602 api: name: airqo-view-api label: view-api diff --git a/k8s/view/values-stage.yaml b/k8s/view/values-stage.yaml index d5b5f53b12..b0d28ccd78 100644 --- a/k8s/view/values-stage.yaml +++ b/k8s/view/values-stage.yaml @@ -13,7 +13,6 @@ api: podAnnotations: {} resources: limits: - cpu: 100m memory: 1000Mi requests: cpu: 5m diff --git a/k8s/website/values-prod.yaml b/k8s/website/values-prod.yaml index 5dfa6eb518..abb085d889 100644 --- a/k8s/website/values-prod.yaml +++ b/k8s/website/values-prod.yaml @@ -6,7 +6,7 @@ app: replicaCount: 3 image: repository: eu.gcr.io/airqo-250220/airqo-website-api - tag: prod-ce82c1ac-1734679890 + tag: prod-c1d1fce6-1735895602 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/website/values-stage.yaml b/k8s/website/values-stage.yaml index a85c7a78ca..51dddc2619 100644 --- a/k8s/website/values-stage.yaml +++ b/k8s/website/values-stage.yaml @@ -6,13 +6,12 @@ app: replicaCount: 2 image: repository: eu.gcr.io/airqo-250220/airqo-stage-website-api - tag: stage-7a1d5dd3-1733836788 + tag: stage-6ffb3fe6-1735572850 nameOverride: '' fullnameOverride: '' podAnnotations: {} resources: limits: - cpu: 100m memory: 400Mi requests: cpu: 10m diff --git a/k8s/workflows/values-prod.yaml b/k8s/workflows/values-prod.yaml index c9b7986601..16ca68b66b 100644 --- a/k8s/workflows/values-prod.yaml +++ b/k8s/workflows/values-prod.yaml @@ -10,7 +10,7 @@ images: initContainer: eu.gcr.io/airqo-250220/airqo-workflows-xcom redisContainer: eu.gcr.io/airqo-250220/airqo-redis containers: eu.gcr.io/airqo-250220/airqo-workflows - tag: prod-ce82c1ac-1734679890 + tag: prod-6d2ca253-1736194337 nameOverride: '' fullnameOverride: '' podAnnotations: {} diff --git a/k8s/workflows/values-stage.yaml b/k8s/workflows/values-stage.yaml index 146d3fdd3b..61475c9ad3 100644 --- a/k8s/workflows/values-stage.yaml +++ b/k8s/workflows/values-stage.yaml @@ -10,7 +10,7 @@ images: initContainer: eu.gcr.io/airqo-250220/airqo-stage-workflows-xcom redisContainer: eu.gcr.io/airqo-250220/airqo-stage-redis containers: eu.gcr.io/airqo-250220/airqo-stage-workflows - tag: stage-e05bdc71-1734549206 + tag: stage-6ffb3fe6-1735572850 nameOverride: '' fullnameOverride: '' podAnnotations: {} @@ -20,28 +20,24 @@ resources: cpu: 125m memory: 500Mi limits: - cpu: 1000m memory: 1500Mi scheduler: requests: cpu: 125m memory: 500Mi limits: - cpu: 2000m memory: 2000Mi celery: requests: cpu: 125m memory: 500Mi limits: - cpu: 2000m memory: 2000Mi redis: requests: cpu: 50m memory: 125Mi limits: - cpu: 1000m memory: 2000Mi volumeMounts: - name: config-volume diff --git a/src/auth-service/bin/jobs/profile-picture-update-job.js b/src/auth-service/bin/jobs/profile-picture-update-job.js new file mode 100644 index 0000000000..be59b454bf --- /dev/null +++ b/src/auth-service/bin/jobs/profile-picture-update-job.js @@ -0,0 +1,198 @@ +const cron = require("node-cron"); +const NetworkModel = require("@models/Network"); +const GroupModel = require("@models/Group"); +const mongoose = require("mongoose"); +const constants = require("@config/constants"); +const log4js = require("log4js"); +const { logText, logObject } = require("@utils/log"); +const logger = log4js.getLogger( + `${constants.ENVIRONMENT} -- bin/jobs/profile-picture-update-job` +); +const stringify = require("@utils/stringify"); +const isEmpty = require("is-empty"); + +// Configuration +const BATCH_SIZE = 100; +const DEFAULT_PROFILE_PICTURE = constants.DEFAULT_ORGANISATION_PROFILE_PICTURE; +const MAX_CONCURRENT_OPERATIONS = 5; // Limit concurrent operations + +// Function to validate URL +const isValidUrl = (url) => { + const urlRegex = + /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; + return urlRegex.test(url); +}; + +// Function to validate the default profile picture +const validateDefaultProfilePicture = () => { + if (!isValidUrl(DEFAULT_PROFILE_PICTURE)) { + logger.error( + `🚨 Aborting profile picture update: Invalid default profile picture URL` + ); + return false; + } + return true; +}; + +// Generic function to process items in batches with controlled concurrency +async function processBatch(items, processFunction) { + const chunks = []; + for (let i = 0; i < items.length; i += MAX_CONCURRENT_OPERATIONS) { + chunks.push(items.slice(i, i + MAX_CONCURRENT_OPERATIONS)); + } + + for (const chunk of chunks) { + await Promise.all(chunk.map(processFunction)); + } +} + +// Function to update a single network +async function updateNetworkProfilePicture(network) { + try { + await NetworkModel("airqo").findByIdAndUpdate( + network._id, + { + $set: { net_profile_picture: DEFAULT_PROFILE_PICTURE }, + }, + { + new: true, + runValidators: true, + } + ); + logger.info(`✅ Updated profile picture for network: ${network.net_name}`); + return { success: true, type: "network", name: network.net_name }; + } catch (error) { + logger.error( + `🐛 Failed to update profile picture for network ${ + network.net_name + }: ${stringify(error)}` + ); + return { success: false, type: "network", name: network.net_name, error }; + } +} + +// Function to update a single group +async function updateGroupProfilePicture(group) { + try { + await GroupModel("airqo").findByIdAndUpdate( + group._id, + { + $set: { grp_profile_picture: DEFAULT_PROFILE_PICTURE }, + }, + { + new: true, + runValidators: true, + } + ); + logger.info(`✅ Updated profile picture for group: ${group.grp_title}`); + return { success: true, type: "group", name: group.grp_title }; + } catch (error) { + logger.error( + `🐛 Failed to update profile picture for group ${ + group.grp_title + }: ${stringify(error)}` + ); + return { success: false, type: "group", name: group.grp_title, error }; + } +} + +// Main function to update profile pictures +async function updateProfilePictures() { + // Validate default profile picture before proceeding + if (!validateDefaultProfilePicture()) { + return; + } + + const stats = { + networks: { processed: 0, success: 0, error: 0 }, + groups: { processed: 0, success: 0, error: 0 }, + }; + + try { + const startTime = Date.now(); + logger.info("🚀 Starting profile picture update process"); + + // Process both networks and groups in parallel + await Promise.all([ + // Update Networks + (async () => { + let skip = 0; + while (true) { + const networks = await NetworkModel("airqo") + .find({ + $or: [ + { net_profile_picture: { $exists: false } }, + { net_profile_picture: null }, + ], + }) + .limit(BATCH_SIZE) + .skip(skip) + .select("_id net_name net_profile_picture") + .lean(); + + if (networks.length === 0) break; + + const results = await processBatch( + networks, + updateNetworkProfilePicture + ); + stats.networks.processed += networks.length; + skip += BATCH_SIZE; + } + })(), + + // Update Groups + (async () => { + let skip = 0; + while (true) { + const groups = await GroupModel("airqo") + .find({ + $or: [ + { grp_profile_picture: { $exists: false } }, + { grp_profile_picture: null }, + ], + }) + .limit(BATCH_SIZE) + .skip(skip) + .select("_id grp_title grp_profile_picture") + .lean(); + + if (groups.length === 0) break; + + const results = await processBatch(groups, updateGroupProfilePicture); + stats.groups.processed += groups.length; + skip += BATCH_SIZE; + } + })(), + ]); + + const duration = (Date.now() - startTime) / 1000; + logText(` + 📊 Profile picture update completed in ${duration} seconds + Networks processed: ${stats.networks.processed} + Groups processed: ${stats.groups.processed} + `); + logger.info(` + 📊 Profile picture update completed in ${duration} seconds + Networks processed: ${stats.networks.processed} + Groups processed: ${stats.groups.processed} + `); + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Error in updateProfilePictures: ${stringify(error)}`); + } +} + +// // Schedule the job to run daily at midnight +const schedule = "0 0 * * *"; +cron.schedule(schedule, updateProfilePictures, { + scheduled: true, + timezone: "Africa/Nairobi", +}); + +// Export for manual execution if needed +module.exports = { + updateProfilePictures, + updateNetworkProfilePicture, + updateGroupProfilePicture, +}; diff --git a/src/auth-service/bin/jobs/update-user-activities-job.js b/src/auth-service/bin/jobs/update-user-activities-job.js new file mode 100644 index 0000000000..8b28ab556e --- /dev/null +++ b/src/auth-service/bin/jobs/update-user-activities-job.js @@ -0,0 +1,325 @@ +const cron = require("node-cron"); +const constants = require("@config/constants"); +const log4js = require("log4js"); +const logger = log4js.getLogger( + `${constants.ENVIRONMENT} -- user-activity-job` +); +const { logText, logObject } = require("@utils/log"); +const { LogModel } = require("@models/log"); +const ActivityModel = require("@models/Activity"); + +const BATCH_SIZE = 500; +const MAX_RETRIES = 3; +const CONCURRENT_BATCH_LIMIT = 5; + +// Helper function for calculating engagement score +function calculateEngagementScore({ + totalActions, + uniqueServices, + uniqueEndpoints, + activityDays, +}) { + const actionsPerDay = totalActions / Math.max(activityDays, 30); + const serviceDiversity = uniqueServices / 20; // Normalize against max expected services + const endpointDiversity = Math.min(uniqueEndpoints / 10, 1); + return ( + (actionsPerDay * 0.4 + serviceDiversity * 0.3 + endpointDiversity * 0.3) * + 100 + ); +} + +function calculateEngagementTier(score) { + if (score >= 80) return "Elite User"; + if (score >= 65) return "Super User"; + if (score >= 45) return "High Engagement"; + if (score >= 25) return "Moderate Engagement"; + return "Low Engagement"; +} + +async function processDailyStats(logs) { + const dailyGroups = new Map(); + + for await (const log of logs) { + const date = new Date(log.timestamp); + date.setHours(0, 0, 0, 0); + const dateKey = date.toISOString(); + + if (!dailyGroups.has(dateKey)) { + dailyGroups.set(dateKey, { + date, + totalActions: 0, + services: new Map(), + endpoints: new Map(), + }); + } + + const dayStats = dailyGroups.get(dateKey); + dayStats.totalActions++; + + dayStats.services.set( + log.meta.service, + (dayStats.services.get(log.meta.service) || 0) + 1 + ); + dayStats.endpoints.set( + log.meta.endpoint, + (dayStats.endpoints.get(log.meta.endpoint) || 0) + 1 + ); + } + + return Array.from(dailyGroups.values()).map((stats) => ({ + date: stats.date, + totalActions: stats.totalActions, + services: Array.from(stats.services.entries()).map(([name, count]) => ({ + name, + count, + })), + endpoints: Array.from(stats.endpoints.entries()).map(([name, count]) => ({ + name, + count, + })), + })); +} + +async function processMonthlyStats(dailyStats) { + const monthlyGroups = new Map(); + + for (const day of dailyStats) { + const year = day.date.getFullYear(); + const month = day.date.getMonth() + 1; + const key = `${year}-${month}`; + + if (!monthlyGroups.has(key)) { + monthlyGroups.set(key, { + year, + month, + totalActions: 0, + services: new Set(), + endpoints: new Set(), + serviceCount: new Map(), + firstActivity: day.date, + lastActivity: day.date, + }); + } + + const monthStats = monthlyGroups.get(key); + monthStats.totalActions += day.totalActions; + + day.services.forEach(({ name, count }) => { + monthStats.services.add(name); + const currentCount = monthStats.serviceCount.get(name) || 0; + monthStats.serviceCount.set(name, currentCount + count); + }); + + day.endpoints.forEach(({ name }) => { + monthStats.endpoints.add(name); + }); + + if (day.date < monthStats.firstActivity) + monthStats.firstActivity = day.date; + if (day.date > monthStats.lastActivity) monthStats.lastActivity = day.date; + } + + return Array.from(monthlyGroups.values()).map((month) => { + const topServices = Array.from(month.serviceCount.entries()) + .map(([name, count]) => ({ name, count })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const activityDays = Math.ceil( + (month.lastActivity - month.firstActivity) / (1000 * 60 * 60 * 24) + ); + + const engagementScore = calculateEngagementScore({ + totalActions: month.totalActions, + uniqueServices: month.services.size, + uniqueEndpoints: month.endpoints.size, + activityDays, + }); + + return { + year: month.year, + month: month.month, + totalActions: month.totalActions, + uniqueServices: Array.from(month.services), + uniqueEndpoints: Array.from(month.endpoints), + topServices, + firstActivity: month.firstActivity, + lastActivity: month.lastActivity, + engagementScore, + engagementTier: calculateEngagementTier(engagementScore), + }; + }); +} +async function getLastProcessedLogs(tenant) { + const lastProcessedMap = new Map(); + + const cursor = await ActivityModel(tenant) + .find({}, { email: 1, lastProcessedLog: 1 }) + .lean() + .cursor(); + + for await (const doc of cursor) { + lastProcessedMap.set(doc.email, doc.lastProcessedLog); + } + + return lastProcessedMap; +} + +async function processUserLogs( + tenant, + email, + lastProcessedLog, + retryCount = 0 +) { + try { + const query = { + "meta.email": email, + "meta.service": { $nin: ["unknown", "none", "", null] }, + }; + if (lastProcessedLog) query._id = { $gt: lastProcessedLog }; + + const cursor = await LogModel(tenant) + .find(query) + .sort({ timestamp: 1 }) + .lean() + .cursor(); + + const logs = []; + for await (const log of cursor) { + logs.push(log); + } + + if (logs.length === 0) return null; + + const dailyStats = await processDailyStats(logs); + const monthlyStats = await processMonthlyStats(dailyStats); + + return { + updateOne: { + filter: { email, tenant }, + update: { + $push: { + dailyStats: { $each: dailyStats, $sort: { date: 1 } }, + monthlyStats: { $each: monthlyStats, $sort: { year: 1, month: 1 } }, + }, + $set: { + lastProcessedLog: logs[logs.length - 1]._id, + username: logs[logs.length - 1].meta.username, + "overallStats.lastActivity": logs[logs.length - 1].timestamp, + tenant, + }, + $min: { "overallStats.firstActivity": logs[0].timestamp }, + $inc: { "overallStats.totalActions": logs.length }, + }, + upsert: true, + }, + }; + } catch (error) { + if (retryCount < MAX_RETRIES) { + await new Promise((resolve) => + setTimeout(resolve, 1000 * (retryCount + 1)) + ); + return processUserLogs(tenant, email, lastProcessedLog, retryCount + 1); + } + throw error; + } +} + +async function processEmailBatch(batch, tenant, lastProcessedMap) { + const updates = await Promise.all( + batch.map((email) => + processUserLogs(tenant, email, lastProcessedMap.get(email)).catch( + (error) => { + logger.error(`Error processing user ${email}: ${error.message}`); + return null; + } + ) + ) + ); + + const validUpdates = updates.filter(Boolean); + if (validUpdates.length > 0) { + await ActivityModel(tenant).bulkWrite(validUpdates, { ordered: false }); + } + return validUpdates.length; +} + +async function updateUserActivities({ tenant = "airqo" } = {}, retryCount = 0) { + const startTime = Date.now(); + let processedCount = 0; + let errorCount = 0; + const timeout = 30000; // 30 seconds + + try { + logText("Starting user activity update job"); + + const query = { + "meta.email": { $exists: true, $ne: null }, + "meta.service": { $nin: ["unknown", "none", "", null] }, + }; + + const uniqueEmails = await LogModel(tenant) + .distinct("meta.email", query) + .maxTimeMS(timeout); + if (uniqueEmails.length === 0) + return { success: true, processedCount: 0, errorCount: 0, duration: 0 }; + + const lastProcessedMap = await getLastProcessedLogs(tenant); + const batches = []; + + for (let i = 0; i < uniqueEmails.length; i += BATCH_SIZE) { + batches.push(uniqueEmails.slice(i, i + BATCH_SIZE)); + } + + for (let i = 0; i < batches.length; i += CONCURRENT_BATCH_LIMIT) { + const currentBatches = batches.slice(i, i + CONCURRENT_BATCH_LIMIT); + const results = await Promise.all( + currentBatches.map((batch) => + processEmailBatch(batch, tenant, lastProcessedMap) + ) + ); + + processedCount += results.reduce((a, b) => a + b, 0); + + logger.info( + `Processed batch ${i + 1}/${batches.length}: ${processedCount} users` + ); + + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + + const duration = (Date.now() - startTime) / 1000; + logText( + `Job completed. Processed ${processedCount} users in ${duration}s. Errors: ${errorCount}` + ); + logger.info( + `Job completed. Processed ${processedCount} users in ${duration}s. Errors: ${errorCount}` + ); + return { success: true, processedCount, errorCount, duration }; + } catch (error) { + logger.error(`Fatal error in updateUserActivities: ${error.message}`); + if (error.message.includes("timed out") && retryCount < MAX_RETRIES) { + const delay = Math.pow(2, retryCount) * 1000; + await new Promise((resolve) => setTimeout(resolve, delay)); + return updateUserActivities({ tenant }, retryCount + 1); + } + throw error; + } +} + +async function runUpdateUserActivities() { + try { + const result = await updateUserActivities({ tenant: "airqo" }); + logObject("Run result", result); + } catch (error) { + logObject("Run error", error); + logger.error(`Cron job error: ${error.message}`); + } +} + +cron.schedule("0 * * * *", runUpdateUserActivities, { + scheduled: true, + timezone: "Africa/Nairobi", +}); + +module.exports = { updateUserActivities }; diff --git a/src/auth-service/bin/server.js b/src/auth-service/bin/server.js index a3002f83dc..94e03cdffd 100644 --- a/src/auth-service/bin/server.js +++ b/src/auth-service/bin/server.js @@ -24,6 +24,8 @@ require("@bin/jobs/token-expiration-job"); require("@bin/jobs/incomplete-profile-job"); require("@bin/jobs/preferences-log-job"); require("@bin/jobs/preferences-update-job"); +// require("@bin/jobs/update-user-activities-job"); +require("@bin/jobs/profile-picture-update-job"); const log4js = require("log4js"); const debug = require("debug")("auth-service:server"); const isEmpty = require("is-empty"); diff --git a/src/auth-service/config/global/db-projections.js b/src/auth-service/config/global/db-projections.js index 63b0e5f79d..bcfd78edd1 100644 --- a/src/auth-service/config/global/db-projections.js +++ b/src/auth-service/config/global/db-projections.js @@ -6,6 +6,7 @@ const dbProjections = { net_email: 1, net_website: 1, net_category: 1, + net_profile_picture: 1, net_status: 1, net_phoneNumber: 1, net_name: 1, @@ -647,6 +648,7 @@ const dbProjections = { grp_tasks: 1, grp_description: 1, grp_website: 1, + grp_profile_picture: 1, grp_industry: 1, grp_country: 1, grp_timezone: 1, @@ -732,6 +734,126 @@ const dbProjections = { } return projection; }, + ACTIVITIES_INCLUSION_PROJECTION: { + _id: 1, + email: 1, + username: 1, + tenant: 1, + dailyStats: 1, + overallStats: 1, + createdAt: 1, + totalMonthlyActions: { + $cond: { + if: { $isArray: "$monthlyStats" }, + then: { $sum: "$monthlyStats.totalActions" }, + else: 0, + }, + }, + // Calculate average engagement score across all months + averageEngagement: { + $cond: { + if: { $isArray: "$monthlyStats" }, + then: { + $avg: "$monthlyStats.engagementScore", + }, + else: 0, + }, + }, + // Get the most recent monthly stats + currentMonthStats: { + $arrayElemAt: [ + { + $filter: { + input: "$monthlyStats", + as: "month", + cond: { + $and: [ + { $eq: ["$$month.year", { $year: new Date() }] }, + { $eq: ["$$month.month", { $month: new Date() }] }, + ], + }, + }, + }, + 0, + ], + }, + // Get today's stats + todayStats: { + $arrayElemAt: [ + { + $filter: { + input: "$dailyStats", + as: "day", + cond: { + $eq: [ + { $dateToString: { format: "%Y-%m-%d", date: "$$day.date" } }, + { $dateToString: { format: "%Y-%m-%d", date: new Date() } }, + ], + }, + }, + }, + 0, + ], + }, + }, + ACTIVITIES_EXCLUSION_PROJECTION: function (category) { + const initialProjection = { + __v: 0, + "dailyStats.__v": 0, + "monthlyStats.__v": 0, + // Exclude specific fields from dailyStats when not needed + "dailyStats.endpoints._id": 0, + "dailyStats.services._id": 0, + // Exclude specific fields from monthlyStats when not needed + "monthlyStats.topServices._id": 0, + lastProcessedLog: 0, + }; + + let projection = Object.assign({}, initialProjection); + + switch (category) { + case "summary": + // For summary view, exclude detailed stats + projection = Object.assign({}, projection, { + dailyStats: 0, + monthlyStats: 0, + lastProcessedLog: 0, + }); + break; + + case "daily": + // For daily view, exclude monthly stats + projection = Object.assign({}, projection, { + monthlyStats: 0, + lastProcessedLog: 0, + }); + break; + + case "monthly": + // For monthly view, exclude daily stats + projection = Object.assign({}, projection, { + dailyStats: 0, + lastProcessedLog: 0, + }); + break; + + case "minimal": + // For minimal view, only show essential fields + projection = { + dailyStats: 0, + monthlyStats: 0, + lastProcessedLog: 0, + createdAt: 0, + updatedAt: 0, + __v: 0, + }; + break; + + // Default case keeps all fields except those in initialProjection + } + + return projection; + }, LOCATION_HISTORIES_INCLUSION_PROJECTION: { _id: 1, name: 1, diff --git a/src/auth-service/config/global/envs.js b/src/auth-service/config/global/envs.js index 0c0f79d332..98898f4c72 100644 --- a/src/auth-service/config/global/envs.js +++ b/src/auth-service/config/global/envs.js @@ -61,5 +61,7 @@ const envs = { SESSION_SECRET: process.env.SESSION_SECRET, HARDWARE_AND_DS_EMAILS: process.env.HARDWARE_AND_DS_EMAILS, PLATFORM_AND_DS_EMAILS: process.env.PLATFORM_AND_DS_EMAILS, + DEFAULT_ORGANISATION_PROFILE_PICTURE: + process.env.DEFAULT_ORGANISATION_PROFILE_PICTURE, }; module.exports = envs; diff --git a/src/auth-service/config/new_database.js b/src/auth-service/config/new_database.js new file mode 100644 index 0000000000..058b68c5e4 --- /dev/null +++ b/src/auth-service/config/new_database.js @@ -0,0 +1,115 @@ +const mongoose = require("mongoose"); +const constants = require("./constants"); +const log4js = require("log4js"); +const logger = log4js.getLogger(`${constants.ENVIRONMENT} -- config-database`); + +const options = { + useNewUrlParser: true, + useUnifiedTopology: true, + autoIndex: true, + poolSize: 20, + bufferMaxEntries: 0, + connectTimeoutMS: 30000, + socketTimeoutMS: 45000, + serverSelectionTimeoutMS: 30000, + keepAlive: true, + keepAliveInitialDelay: 300000, + maxPoolSize: 50, + minPoolSize: 10, + maxIdleTimeMS: 60000, + writeConcern: { + w: "majority", + j: true, + wtimeout: 30000, + }, +}; + +const connectionMap = new Map(); + +const connect = (dbName = constants.DB_NAME) => { + const URI = constants.MONGO_URI; + return mongoose.createConnection(URI, { ...options, dbName }); +}; + +const handleConnection = (db, dbName) => { + db.on("connected", () => { + // logger.info(`Connected to database: ${dbName}`); + }); + + db.on("error", (err) => { + // logger.error(`Database connection error for ${dbName}: ${err.message}`); + setTimeout(() => reconnect(dbName), 5000); + }); + + db.on("disconnected", () => { + // logger.warn(`Database disconnected: ${dbName}`); + setTimeout(() => reconnect(dbName), 5000); + }); + + return db; +}; + +const reconnect = async (dbName) => { + try { + const existingConn = connectionMap.get(dbName); + if (existingConn && existingConn.readyState !== 1) { + const db = connect(dbName); + handleConnection(db, dbName); + connectionMap.set(dbName, db); + } + } catch (error) { + // logger.error(`Reconnection failed for ${dbName}: ${error.message}`); + } +}; + +const connectToMongoDB = () => { + try { + const db = connect(); + handleConnection(db, constants.DB_NAME); + connectionMap.set(constants.DB_NAME, db); + + process.on("unhandledRejection", (reason, promise) => { + // logger.error("Unhandled Rejection:", reason); + }); + + process.on("uncaughtException", (err) => { + // logger.error("Uncaught Exception:", err); + }); + + return db; + } catch (error) { + // logger.error(`Database initialization error: ${error.message}`); + throw error; + } +}; + +const getTenantDB = (tenantId, modelName, schema) => { + try { + const dbName = `${constants.DB_NAME}_${tenantId}`; + let db = connectionMap.get(dbName); + + if (!db || db.readyState !== 1) { + db = connect(dbName); + handleConnection(db, dbName); + connectionMap.set(dbName, db); + } + + if (!db.models[modelName]) { + db.model(modelName, schema); + } + + return db; + } catch (error) { + // logger.error(`Error getting tenant DB: ${error.message}`); + throw error; + } +}; + +const getModelByTenant = (tenantId, modelName, schema) => { + const tenantDb = getTenantDB(tenantId, modelName, schema); + return tenantDb.model(modelName); +}; + +const mongodb = connectToMongoDB(); + +module.exports = { getModelByTenant, getTenantDB, connectToMongoDB }; diff --git a/src/auth-service/controllers/create-analytics.js b/src/auth-service/controllers/create-analytics.js new file mode 100644 index 0000000000..e1803536c0 --- /dev/null +++ b/src/auth-service/controllers/create-analytics.js @@ -0,0 +1,840 @@ +const createAnalyticsUtil = require("@utils/create-analytics"); +const constants = require("@config/constants"); +const { isEmpty } = require("lodash"); +const httpStatus = require("http-status"); +const { extractErrorsFromRequest, HttpError } = require("@utils/errors"); +const log4js = require("log4js"); +const logger = log4js.getLogger( + `${constants.ENVIRONMENT} -- create-analytics-controller` +); +const { logText, logObject } = require("@utils/log"); + +function handleResponse({ + result, + key = "data", + errorKey = "errors", + res, +} = {}) { + if (!result) { + return; + } + + const isSuccess = result.success; + const defaultStatus = isSuccess + ? httpStatus.OK + : httpStatus.INTERNAL_SERVER_ERROR; + + const defaultMessage = isSuccess + ? "Operation Successful" + : "Internal Server Error"; + + const status = result.status !== undefined ? result.status : defaultStatus; + const message = + result.message !== undefined ? result.message : defaultMessage; + const data = result.data !== undefined ? result.data : []; + const errors = isSuccess + ? undefined + : result.errors !== undefined + ? result.errors + : { message: "Internal Server Error" }; + + return res.status(status).json({ message, [key]: data, [errorKey]: errors }); +} + +const analytics = { + send: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError( + "bad request errors", + httpStatus.BAD_REQUEST, + res, + errors + ) + ); + return; + } + + const request = Object.assign({}, req); + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.sendYearEndEmails(request); + + if (result.success) { + res.status(result.status || httpStatus.OK).json({ + success: true, + message: result.message, + }); + } else { + res.status(result.status || httpStatus.INTERNAL_SERVER_ERROR).json({ + success: false, + message: result.message, + }); + } + } catch (error) { + logger.error(`🐛🐛 Year-End Email Error: ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + fetchUserStats: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + + const { body, query } = req; + const { emails } = body; + const { year } = query; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + + const request = { + emails, + year, + tenant: isEmpty(req.query.tenant) ? defaultTenant : req.query.tenant, + }; + + const result = await createAnalyticsUtil.fetchUserStats(request); + + if (result) { + res.status(result.status || httpStatus.OK).json({ + success: true, + message: result.message || "Successfully retrieved the User Stats", + stats: result, + }); + } else { + res.status(result.status || httpStatus.INTERNAL_SERVER_ERROR).json({ + success: false, + message: result.message || "No Stats Available for this User", + }); + } + } catch (error) { + logger.error(`🐛🐛 fetchUserStats Error: ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + validateEnvironment: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + + const { body, query } = req; + const { year } = query; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + + const validation = await createAnalyticsUtil.validateEnvironmentData({ + tenant: isEmpty(req.query.tenant) ? defaultTenant : req.query.tenant, + year, + }); + res.json(validation); + } catch (error) { + logger.error(`🐛🐛 validateEnvironment Error: ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + listStatistics: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + const tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.listStatistics(tenant, next); + + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + return res.status(status).json({ + success: true, + message: result.message, + users_stats: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + return res.status(status).json({ + success: false, + message: result.message, + errors: { + message: result.errors + ? result.errors + : { message: "Internal Server Error" }, + }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + listLogs: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.listLogs(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "users_stats", + res, + }); + } + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + listActivities: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.listActivities(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "user_activities", + res, + }); + } + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + getUserStats: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getUserStats(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } + logObject("result", result); + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + return res.status(status).json({ + success: true, + message: result.message, + users_stats: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + return res.status(status).json({ + success: false, + message: result.message, + errors: result.errors + ? result.errors + : { message: "Internal Server Errors" }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + // User Engagement Functions + getUserEngagement: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getUserEngagement(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "user_engagements", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + getEngagementMetrics: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getEngagementMetrics( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "engagement_metrics", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Activity Analysis Functions + getActivityReport: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getActivityReport(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "activity_report", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Cohort Analysis Functions + getCohortAnalysis: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getCohortAnalysis(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "cohort_analysis", + res, + }); + } + } catch (error) { + logObject("error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Predictive Analytics Functions + getPredictiveAnalytics: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getPredictiveAnalytics( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "predictive_analytics", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Service Adoption Functions + getServiceAdoption: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getServiceAdoption( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "service_adoption", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Benchmark Functions + getBenchmarks: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getBenchmarks(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "benchmarks", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Top Users Functions + getTopUsers: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getTopUsers(request, next); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "top_users", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Aggregated Analytics Functions + getAggregatedAnalytics: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getAggregatedAnalytics( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "aggregated_analytics", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Retention Analysis Functions + getRetentionAnalysis: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getRetentionAnalysis( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "retention_analysis", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Health Score Functions + getEngagementHealth: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getEngagementHealth( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "engagement_health", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Behavior Pattern Functions + getBehaviorPatterns: async (req, res, next) => { + try { + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_req, res, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createAnalyticsUtil.getBehaviorPatterns( + request, + next + ); + + if (isEmpty(result) || res.headersSent) { + return; + } else { + handleResponse({ + result, + key: "behavior_patterns", + res, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, +}; + +module.exports = analytics; diff --git a/src/auth-service/controllers/create-group.js b/src/auth-service/controllers/create-group.js index 8137f11581..3063ab39bb 100644 --- a/src/auth-service/controllers/create-group.js +++ b/src/auth-service/controllers/create-group.js @@ -515,6 +515,58 @@ const createGroup = { return; } }, + setManager: async (req, res, next) => { + try { + logText("set the manager...."); + const errors = extractErrorsFromRequest(req); + if (errors) { + next( + new HttpError("bad request errors", httpStatus.BAD_REQUEST, errors) + ); + return; + } + const request = req; + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + request.query.tenant = isEmpty(req.query.tenant) + ? defaultTenant + : req.query.tenant; + + const result = await createGroupUtil.setManager(request, next); + if (isEmpty(result) || res.headersSent) { + return; + } + + if (result.success === true) { + const status = result.status ? result.status : httpStatus.OK; + return res.status(status).json({ + success: true, + message: "Group manager successffuly set", + updated_group: result.data, + }); + } else if (result.success === false) { + const status = result.status + ? result.status + : httpStatus.INTERNAL_SERVER_ERROR; + return res.status(status).json({ + success: false, + message: result.message, + errors: result.errors + ? result.errors + : { message: "Internal Server Error" }, + }); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, listAssignedUsers: async (req, res, next) => { try { const errors = extractErrorsFromRequest(req); diff --git a/src/auth-service/models/Activity.js b/src/auth-service/models/Activity.js new file mode 100644 index 0000000000..efd6aba748 --- /dev/null +++ b/src/auth-service/models/Activity.js @@ -0,0 +1,312 @@ +const mongoose = require("mongoose"); +const ObjectId = mongoose.Schema.Types.ObjectId; +const { Schema } = mongoose; +var uniqueValidator = require("mongoose-unique-validator"); +const { logObject } = require("@utils/log"); +const constants = require("@config/constants"); +const isEmpty = require("is-empty"); +const { getModelByTenant } = require("@config/database"); +const httpStatus = require("http-status"); +const { HttpError } = require("@utils/errors"); +const log4js = require("log4js"); +const logger = log4js.getLogger(`${constants.ENVIRONMENT} -- activity-model`); + +const activitySchema = new Schema( + { + email: { type: String, required: true }, + username: { type: String }, + tenant: { type: String, required: true }, + // Daily stats + dailyStats: [ + { + date: { type: Date, required: true }, + totalActions: { type: Number, default: 0 }, + services: [ + { + name: String, + count: Number, + }, + ], + endpoints: [ + { + name: String, + count: Number, + }, + ], + }, + ], + monthlyStats: [ + { + year: { type: Number, required: true }, + month: { type: Number, required: true }, + totalActions: { type: Number, default: 0 }, + uniqueServices: [String], + uniqueEndpoints: [String], + topServices: [ + { + name: String, + count: Number, + }, + ], + engagementScore: Number, + engagementTier: String, + firstActivity: Date, + lastActivity: Date, + }, + ], + overallStats: { + totalActions: { type: Number, default: 0 }, + firstActivity: Date, + lastActivity: Date, + engagementScore: Number, + engagementTier: String, + }, + lastProcessedLog: { type: mongoose.Schema.Types.ObjectId, ref: "logs" }, + }, + { + timestamps: true, + indexes: [ + { email: 1, tenant: 1 }, + { tenant: 1, "monthlyStats.year": 1, "monthlyStats.month": 1 }, + { "dailyStats.date": 1 }, + { lastProcessedLog: 1 }, + ], + } +); + +activitySchema.plugin(uniqueValidator, { + message: `{VALUE} should be unique!`, +}); + +activitySchema.methods = { + toJSON() { + return { + _id: this._id, + }; + }, +}; + +activitySchema.statics = { + async register(args, next) { + try { + const data = await this.create({ + ...args, + }); + if (!isEmpty(data)) { + return { + success: true, + data, + message: "activity created", + status: httpStatus.OK, + }; + } else if (isEmpty(data)) { + return { + success: true, + data, + message: "activity NOT successfully created but operation successful", + status: httpStatus.ACCEPTED, + }; + } + } catch (err) { + let response = {}; + let errors = {}; + let message = "Internal Server Error"; + let status = httpStatus.INTERNAL_SERVER_ERROR; + if (err.code === 11000 || err.code === 11001) { + errors = err.keyValue; + message = "duplicate values provided"; + status = httpStatus.CONFLICT; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value); + }); + } else { + message = "validation errors for some of the provided fields"; + status = httpStatus.CONFLICT; + errors = err.errors; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value.message); + }); + } + logger.error(`🐛🐛 Internal Server Error -- ${err.message}`); + next(new HttpError(message, status, response)); + } + }, + async list({ skip = 0, limit = 100, filter = {} } = {}, next) { + try { + logObject("filter", filter); + const inclusionProjection = constants.ACTIVITIES_INCLUSION_PROJECTION; + const exclusionProjection = constants.ACTIVITIES_EXCLUSION_PROJECTION( + filter.category ? filter.category : "none" + ); + + if (!isEmpty(filter.category)) { + delete filter.category; + } + + const response = await this.aggregate() + .match(filter) + .sort({ createdAt: -1 }) + .project(inclusionProjection) + .project(exclusionProjection) + .skip(skip ? skip : 0) + .limit(limit ? limit : 100) + .allowDiskUse(true); + + if (!isEmpty(response)) { + return { + success: true, + message: "successfully retrieved the activitys", + data: response, + status: httpStatus.OK, + }; + } else if (isEmpty(response)) { + return { + success: true, + message: "activitys do not exist, please crosscheck", + status: httpStatus.NOT_FOUND, + data: [], + errors: { message: "unable to retrieve activitys" }, + }; + } + } catch (err) { + let response = {}; + let errors = {}; + let message = "Internal Server Error"; + let status = httpStatus.INTERNAL_SERVER_ERROR; + if (err.code === 11000 || err.code === 11001) { + errors = err.keyValue; + message = "duplicate values provided"; + status = httpStatus.CONFLICT; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value); + }); + } else { + message = "validation errors for some of the provided fields"; + status = httpStatus.CONFLICT; + errors = err.errors; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value.message); + }); + } + + logger.error(`🐛🐛 Internal Server Error -- ${err.message}`); + next(new HttpError(message, status, response)); + } + }, + async modify({ filter = {}, update = {} } = {}, next) { + try { + let options = { new: true }; + let modifiedUpdate = Object.assign({}, update); + modifiedUpdate["$addToSet"] = {}; + + if (modifiedUpdate.tenant) { + delete modifiedUpdate.tenant; + } + + const updatedActivity = await this.findOneAndUpdate( + filter, + modifiedUpdate, + options + ).exec(); + + if (!isEmpty(updatedActivity)) { + return { + success: true, + message: "successfully modified the activity", + data: updatedActivity._doc, + status: httpStatus.OK, + }; + } else if (isEmpty(updatedActivity)) { + next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "activity does not exist, please crosscheck -- Not Found", + }) + ); + } + } catch (err) { + let response = {}; + let errors = {}; + let message = "Internal Server Error"; + let status = httpStatus.INTERNAL_SERVER_ERROR; + if (err.code === 11000 || err.code === 11001) { + errors = err.keyValue; + message = "duplicate values provided"; + status = httpStatus.CONFLICT; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value); + }); + } else { + message = "validation errors for some of the provided fields"; + status = httpStatus.CONFLICT; + errors = err.errors; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value.message); + }); + } + logger.error(`🐛🐛 Internal Server Error -- ${err.message}`); + next(new HttpError(message, status, response)); + } + }, + async remove({ filter = {} } = {}, next) { + try { + let options = { + projection: { + _id: 1, + }, + }; + const removedActivity = await this.findOneAndRemove( + filter, + options + ).exec(); + + if (!isEmpty(removedActivity)) { + return { + success: true, + message: "successfully removed the activity", + data: removedActivity._doc, + status: httpStatus.OK, + }; + } else if (isEmpty(removedActivity)) { + next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Bad Request, activity Not Found -- please crosscheck", + }) + ); + } + } catch (err) { + let response = {}; + let errors = {}; + let message = "Internal Server Error"; + let status = httpStatus.INTERNAL_SERVER_ERROR; + if (err.code === 11000 || err.code === 11001) { + errors = err.keyValue; + message = "duplicate values provided"; + status = httpStatus.CONFLICT; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value); + }); + } else { + message = "validation errors for some of the provided fields"; + status = httpStatus.CONFLICT; + errors = err.errors; + Object.entries(errors).forEach(([key, value]) => { + return (response[key] = value.message); + }); + } + logger.error(`🐛🐛 Internal Server Error -- ${err.message}`); + next(new HttpError(message, status, response)); + } + }, +}; + +const ActivityModel = (tenant) => { + const defaultTenant = constants.DEFAULT_TENANT || "airqo"; + const dbTenant = isEmpty(tenant) ? defaultTenant : tenant; + try { + return mongoose.model("activities"); + } catch (error) { + return getModelByTenant(dbTenant, "activity", activitySchema); + } +}; + +module.exports = ActivityModel; diff --git a/src/auth-service/models/Group.js b/src/auth-service/models/Group.js index e590876ca4..6ac0c16502 100644 --- a/src/auth-service/models/Group.js +++ b/src/auth-service/models/Group.js @@ -1,8 +1,9 @@ const mongoose = require("mongoose"); const ObjectId = mongoose.Schema.Types.ObjectId; const { Schema } = mongoose; +const validator = require("validator"); var uniqueValidator = require("mongoose-unique-validator"); -const { logObject } = require("@utils/log"); +const { logObject, logText, logElement } = require("@utils/log"); const constants = require("@config/constants"); const isEmpty = require("is-empty"); const { getModelByTenant } = require("@config/database"); @@ -11,6 +12,23 @@ const { HttpError } = require("@utils/errors"); const log4js = require("log4js"); const logger = log4js.getLogger(`${constants.ENVIRONMENT} -- group-model`); +function validateProfilePicture(grp_profile_picture) { + const urlRegex = + /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; + if (!urlRegex.test(grp_profile_picture)) { + logger.error(`🙅🙅 Bad Request Error -- Not a valid profile picture URL`); + return false; + } + if (grp_profile_picture.length > 200) { + logText("longer than 200 chars"); + logger.error( + `🙅🙅 Bad Request Error -- profile picture URL exceeds 200 characters` + ); + return false; + } + return true; +} + const GroupSchema = new Schema( { grp_title: { @@ -33,6 +51,20 @@ const GroupSchema = new Schema( grp_country: { type: String }, grp_timezone: { type: String }, grp_image: { type: String }, + grp_profile_picture: { + type: String, + maxLength: 200, + default: constants.DEFAULT_ORGANISATION_PROFILE_PICTURE, + validate: { + validator: function (v) { + const urlRegex = + /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; + return urlRegex.test(v); + }, + message: + "Profile picture URL must be a valid URL & must not exceed 200 characters.", + }, + }, }, { timestamps: true, @@ -45,6 +77,55 @@ GroupSchema.plugin(uniqueValidator, { GroupSchema.index({ grp_title: 1 }, { unique: true }); +GroupSchema.pre( + ["updateOne", "findOneAndUpdate", "updateMany", "update", "save"], + async function (next) { + // Determine if this is a new document or an update + const isNew = this.isNew; + let updates = this.getUpdate ? this.getUpdate() : this; + + try { + // Get all actual fields being updated from both root and $set + const actualUpdates = { + ...(updates || {}), + ...(updates.$set || {}), + }; + + // Profile picture validation for both new documents and updates + if (isNew) { + // Validation for new documents + + if (!this.grp_profile_picture) { + this.grp_profile_picture = + constants.DEFAULT_ORGANISATION_PROFILE_PICTURE; + } else if ( + this.grp_profile_picture && + !validateProfilePicture(this.grp_profile_picture) + ) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Invalid profile picture URL", + }) + ); + } + } else if (actualUpdates.grp_profile_picture) { + // Validation for updates + if (!validateProfilePicture(actualUpdates.grp_profile_picture)) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Invalid profile picture URL", + }) + ); + } + } + + return next(); + } catch (error) { + return next(error); + } + } +); + GroupSchema.methods = { toJSON() { return { @@ -59,6 +140,7 @@ GroupSchema.methods = { grp_manager_firstname: this.grp_manager_firstname, grp_manager_lastname: this.grp_manager_lastname, grp_website: this.grp_website, + grp_profile_picture: this.grp_profile_picture, grp_industry: this.grp_industry, grp_country: this.grp_country, grp_timezone: this.grp_timezone, diff --git a/src/auth-service/models/Network.js b/src/auth-service/models/Network.js index 22d1278bc9..7f274ddd0a 100644 --- a/src/auth-service/models/Network.js +++ b/src/auth-service/models/Network.js @@ -13,6 +13,23 @@ const logger = require("log4js").getLogger( ); const { HttpError } = require("@utils/errors"); +function validateProfilePicture(net_profile_picture) { + const urlRegex = + /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; + if (!urlRegex.test(net_profile_picture)) { + logger.error(`🙅🙅 Bad Request Error -- Not a valid profile picture URL`); + return false; + } + if (net_profile_picture.length > 200) { + logText("longer than 200 chars"); + logger.error( + `🙅🙅 Bad Request Error -- profile picture URL exceeds 200 characters` + ); + return false; + } + return true; +} + const NetworkSchema = new Schema( { net_email: { @@ -92,6 +109,20 @@ const NetworkSchema = new Schema( ref: "permission", }, ], + net_profile_picture: { + type: String, + maxLength: 200, + default: constants.DEFAULT_ORGANISATION_PROFILE_PICTURE, + validate: { + validator: function (v) { + const urlRegex = + /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; + return urlRegex.test(v); + }, + message: + "Profile picture URL must be a valid URL & must not exceed 200 characters.", + }, + }, }, { timestamps: true, @@ -139,12 +170,39 @@ NetworkSchema.pre( handleArrayFieldUpdates("net_departments"); handleArrayFieldUpdates("net_permissions"); - // Validation for new documents - if (isNew) { - // Required field validations are handled by schema + // Get all actual fields being updated from both root and $set + const actualUpdates = { + ...(updates || {}), + ...(updates.$set || {}), + }; - // Set default status if not provided + // Profile picture validation for both new documents and updates + if (isNew) { + // Validation for new documents this.net_status = this.net_status || "inactive"; + + if (!this.net_profile_picture) { + this.net_profile_picture = + constants.DEFAULT_ORGANISATION_PROFILE_PICTURE; + } else if ( + this.net_profile_picture && + !validateProfilePicture(this.net_profile_picture) + ) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Invalid profile picture URL", + }) + ); + } + } else if (actualUpdates.net_profile_picture) { + // Validation for updates + if (!validateProfilePicture(actualUpdates.net_profile_picture)) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Invalid profile picture URL", + }) + ); + } } // Unique field handling @@ -208,6 +266,7 @@ NetworkSchema.methods = { _id: this._id, net_email: this.net_email, net_website: this.net_website, + net_profile_picture: this.net_profile_picture, net_category: this.net_category, net_status: this.net_status, net_phoneNumber: this.net_phoneNumber, diff --git a/src/auth-service/models/User.js b/src/auth-service/models/User.js index 3b5bda5b03..9e5fdfaf4f 100644 --- a/src/auth-service/models/User.js +++ b/src/auth-service/models/User.js @@ -141,7 +141,7 @@ const UserSchema = new Schema( validator: function (value) { return value.length <= ORGANISATIONS_LIMIT; }, - message: "Too many networks. Maximum limit: 6.", + message: `Too many networks. Maximum limit: ${ORGANISATIONS_LIMIT}`, }, ], }, @@ -169,7 +169,7 @@ const UserSchema = new Schema( validator: function (value) { return value.length <= ORGANISATIONS_LIMIT; }, - message: "Too many groups. Maximum limit: 6.", + message: `Too many groups. Maximum limit: ${ORGANISATIONS_LIMIT}`, }, ], }, @@ -251,7 +251,16 @@ UserSchema.pre( async function (next) { // Determine if this is a new document or an update const isNew = this.isNew; - let updates = this.getUpdate ? this.getUpdate() : this; + + // Safely get updates object, accounting for different mongoose operations + let updates = {}; + if (this.getUpdate) { + updates = this.getUpdate() || {}; + } else if (!isNew) { + updates = this.toObject(); + } else { + updates = this; + } try { // Helper function to handle role updates @@ -267,20 +276,38 @@ UserSchema.pre( let newRoles = []; const existingRoles = doc[fieldName] || []; - // Handle $set operations + // Initialize update operators safely + updates.$set = updates.$set || {}; + updates.$push = updates.$push || {}; + updates.$addToSet = updates.$addToSet || {}; + + // Handle update operations in order of precedence if (updates.$set && updates.$set[fieldName]) { + // $set takes precedence as it's a direct override newRoles = updates.$set[fieldName]; - } - // Handle $push operations - else if (updates.$push && updates.$push[fieldName]) { + } else if (updates.$push && updates.$push[fieldName]) { + // Safely handle $push with potential $each operator const pushValue = updates.$push[fieldName]; - const newRole = pushValue.$each ? pushValue.$each[0] : pushValue; - newRoles = [...existingRoles, newRole]; - } - // Handle $addToSet operations - else if (updates.$addToSet && updates.$addToSet[fieldName]) { - const newRole = updates.$addToSet[fieldName]; - newRoles = [...existingRoles, newRole]; + if (pushValue) { + if (pushValue.$each && Array.isArray(pushValue.$each)) { + newRoles = [...existingRoles, ...pushValue.$each]; + } else { + newRoles = [...existingRoles, pushValue]; + } + } + } else if (updates.$addToSet && updates.$addToSet[fieldName]) { + // Safely handle $addToSet + const addToSetValue = updates.$addToSet[fieldName]; + if (addToSetValue) { + if (addToSetValue.$each && Array.isArray(addToSetValue.$each)) { + newRoles = [...existingRoles, ...addToSetValue.$each]; + } else { + newRoles = [...existingRoles, addToSetValue]; + } + } + } else if (updates[fieldName]) { + // Direct field update + newRoles = updates[fieldName]; } if (newRoles.length > 0) { @@ -306,14 +333,11 @@ UserSchema.pre( // Convert Map values back to array const finalRoles = Array.from(uniqueRoles.values()); - // Clear all update operators for this field - if (updates.$set) delete updates.$set[fieldName]; - if (updates.$push) delete updates.$push[fieldName]; - if (updates.$addToSet) delete updates.$addToSet[fieldName]; - - // Set the final filtered array - updates.$set = updates.$set || {}; + // Set the final filtered array using $set and clean up other operators updates.$set[fieldName] = finalRoles; + delete updates.$push[fieldName]; + delete updates.$addToSet[fieldName]; + delete updates[fieldName]; // Clean up direct field update if any } }; @@ -334,10 +358,10 @@ UserSchema.pre( if (isNew) { this.password = bcrypt.hashSync(passwordToHash, saltRounds); } else { - if (updates.password) { + if (updates && updates.password) { updates.password = bcrypt.hashSync(passwordToHash, saltRounds); } - if (updates.$set && updates.$set.password) { + if (updates && updates.$set && updates.$set.password) { updates.$set.password = bcrypt.hashSync(passwordToHash, saltRounds); } } @@ -453,6 +477,17 @@ UserSchema.pre( ...(updates.$set || {}), }; + // Profile picture validation for updates + if (actualUpdates.profilePicture) { + if (!validateProfilePicture(actualUpdates.profilePicture)) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Invalid profile picture URL", + }) + ); + } + } + // Conditional validations for updates // Only validate fields that are present in the update fieldsToValidate.forEach((field) => { @@ -472,7 +507,7 @@ UserSchema.pre( const immutableFields = ["firebase_uid", "email", "createdAt", "_id"]; immutableFields.forEach((field) => { if (updates[field]) delete updates[field]; - if (updates.$set && updates.$set[field]) { + if (updates && updates.$set && updates.$set[field]) { return next( new HttpError( "Modification Not Allowed", @@ -481,7 +516,7 @@ UserSchema.pre( ) ); } - if (updates.$set) delete updates.$set[field]; + if (updates && updates.$set) delete updates.$set[field]; if (updates.$push) delete updates.$push[field]; }); @@ -510,7 +545,7 @@ UserSchema.pre( // Conditional permissions validation if (updates.permissions) { const uniquePermissions = [...new Set(updates.permissions)]; - if (updates.$set) { + if (updates && updates.$set) { updates.$set.permissions = uniquePermissions; } else { updates.permissions = uniquePermissions; @@ -518,7 +553,7 @@ UserSchema.pre( } // Conditional default values for updates - if (updates.$set) { + if (updates && updates.$set) { updates.$set.verified = updates.$set.verified ?? false; updates.$set.analyticsVersion = updates.$set.analyticsVersion ?? 2; } else { diff --git a/src/auth-service/models/UserOld.js b/src/auth-service/models/UserOld.js deleted file mode 100644 index 7e59c0b5de..0000000000 --- a/src/auth-service/models/UserOld.js +++ /dev/null @@ -1,939 +0,0 @@ -const mongoose = require("mongoose").set("debug", true); -const Schema = mongoose.Schema; -const validator = require("validator"); -const bcrypt = require("bcrypt"); -const jwt = require("jsonwebtoken"); -const constants = require("@config/constants"); -const { logObject, logText } = require("@utils/log"); -const ObjectId = mongoose.Schema.Types.ObjectId; -const isEmpty = require("is-empty"); -const saltRounds = constants.SALT_ROUNDS; -const httpStatus = require("http-status"); -const accessCodeGenerator = require("generate-password"); -const { getModelByTenant } = require("@config/database"); -const logger = require("log4js").getLogger( - `${constants.ENVIRONMENT} -- user-model` -); -const validUserTypes = ["user", "guest"]; -const { HttpError } = require("@utils/errors"); -const mailer = require("@utils/mailer"); - -function oneMonthFromNow() { - var d = new Date(); - var targetMonth = d.getMonth() + 1; - d.setMonth(targetMonth); - if (d.getMonth() !== targetMonth % 12) { - d.setDate(0); // last day of previous month - } - return d; -} - -function validateProfilePicture(profilePicture) { - const urlRegex = - /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; - if (!urlRegex.test(profilePicture)) { - logger.error(`🙅🙅 Bad Request Error -- Not a valid profile picture URL`); - return false; - } - if (profilePicture.length > 200) { - logText("longer than 200 chars"); - logger.error( - `🙅🙅 Bad Request Error -- profile picture URL exceeds 200 characters` - ); - return false; - } - return true; -} -const passwordReg = /^(?=.*[A-Za-z])(?=.*\d)[A-Za-z\d@#?!$%^&*,.]{6,}$/; -const UserSchema = new Schema( - { - due_date: { type: Date }, - status: { type: String }, - address: { type: String }, - country: { type: String }, - firebase_uid: { type: String }, - city: { type: String }, - department_id: { - type: ObjectId, - ref: "department", - }, - role: { - type: ObjectId, - ref: "role", - }, - birthday: { type: Date }, - reports_to: { type: ObjectId, ref: "user" }, - replaced_by: { type: ObjectId, ref: "user" }, - email: { - type: String, - unique: true, - required: [true, "Email is required"], - trim: true, - validate: { - validator(email) { - return validator.isEmail(email); - }, - message: "{VALUE} is not a valid email!", - }, - }, - verified: { - type: Boolean, - default: false, - }, - analyticsVersion: { type: Number, default: 2 }, - firstName: { - type: String, - required: [true, "FirstName is required!"], - trim: true, - }, - lastName: { - type: String, - required: [true, "LastName is required"], - trim: true, - }, - userName: { - type: String, - required: [true, "UserName is required!"], - trim: true, - unique: true, - }, - password: { - type: String, - required: [true, "Password is required!"], - trim: true, - minlength: [6, "Password is required"], - validate: { - validator(password) { - return passwordReg.test(password); - }, - message: "{VALUE} is not a valid password, please check documentation!", - }, - }, - privilege: { - type: String, - default: "user", - }, - isActive: { type: Boolean, default: false }, - loginCount: { type: Number, default: 0 }, - duration: { type: Date, default: oneMonthFromNow }, - network_roles: { - type: [ - { - network: { - type: ObjectId, - ref: "network", - default: mongoose.Types.ObjectId(constants.DEFAULT_NETWORK), - }, - userType: { type: String, default: "guest", enum: validUserTypes }, - createdAt: { type: String, default: new Date() }, - role: { - type: ObjectId, - ref: "role", - default: mongoose.Types.ObjectId(constants.DEFAULT_NETWORK_ROLE), - }, - }, - ], - default: [], - _id: false, - validate: [ - { - validator: function (value) { - const maxLimit = 6; - return value.length <= maxLimit; - }, - message: "Too many networks. Maximum limit: 6.", - }, - ], - }, - group_roles: { - type: [ - { - group: { - type: ObjectId, - ref: "group", - default: mongoose.Types.ObjectId(constants.DEFAULT_GROUP), - }, - userType: { type: String, default: "guest", enum: validUserTypes }, - createdAt: { type: String, default: new Date() }, - role: { - type: ObjectId, - ref: "role", - default: mongoose.Types.ObjectId(constants.DEFAULT_GROUP_ROLE), - }, - }, - ], - default: [], - _id: false, - validate: [ - { - validator: function (value) { - const maxLimit = 6; - return value.length <= maxLimit; - }, - message: "Too many groups. Maximum limit: 6.", - }, - ], - }, - - permissions: [ - { - type: ObjectId, - ref: "permission", - }, - ], - organization: { - type: String, - default: "airqo", - }, - long_organization: { - type: String, - default: "airqo", - }, - rateLimit: { - type: Number, - }, - phoneNumber: { - type: Number, - validate: { - validator(phoneNumber) { - return !!phoneNumber || this.email; - }, - message: "Phone number or email is required!", - }, - }, - resetPasswordToken: { type: String }, - resetPasswordExpires: { type: Date }, - jobTitle: { - type: String, - }, - website: { type: String }, - description: { type: String }, - lastLogin: { - type: Date, - }, - category: { - type: String, - }, - notifications: { - email: { type: Boolean, default: false }, - push: { type: Boolean, default: false }, - text: { type: Boolean, default: false }, - phone: { type: Boolean, default: false }, - }, - profilePicture: { - type: String, - maxLength: 200, - validate: { - validator: function (v) { - const urlRegex = - /^(http(s)?:\/\/.)[-a-zA-Z0-9@:%._\+~#=]{2,256}\.[a-z]{2,6}\b([-a-zA-Z0-9@:%_\+.~#?&//=]*)$/g; - return urlRegex.test(v); - }, - message: - "Profile picture URL must be a valid URL & must not exceed 200 characters.", - }, - }, - google_id: { type: String, trim: true }, - timezone: { type: String, trim: true }, - }, - { timestamps: true } -); - -UserSchema.path("network_roles.userType").validate(function (value) { - return validUserTypes.includes(value); -}, "Invalid userType value"); - -UserSchema.path("group_roles.userType").validate(function (value) { - return validUserTypes.includes(value); -}, "Invalid userType value"); - -UserSchema.pre("save", function (next) { - if (this.isModified("password")) { - this.password = bcrypt.hashSync(this.password, saltRounds); - } - if (!this.email && !this.phoneNumber) { - return next(new Error("Phone number or email is required!")); - } - - if (!this.network_roles || this.network_roles.length === 0) { - if ( - !constants || - !constants.DEFAULT_NETWORK || - !constants.DEFAULT_NETWORK_ROLE - ) { - throw new HttpError( - "Internal Server Error", - httpStatus.INTERNAL_SERVER_ERROR, - { - message: - "Contact support@airqo.net -- unable to retrieve the default Network or Role to which the User will belong", - } - ); - } - - this.network_roles = [ - { - network: mongoose.Types.ObjectId(constants.DEFAULT_NETWORK), - userType: "guest", - createdAt: new Date(), - role: mongoose.Types.ObjectId(constants.DEFAULT_NETWORK_ROLE), - }, - ]; - } - - if (!this.group_roles || this.group_roles.length === 0) { - if ( - !constants || - !constants.DEFAULT_GROUP || - !constants.DEFAULT_GROUP_ROLE - ) { - throw new HttpError( - "Internal Server Error", - httpStatus.INTERNAL_SERVER_ERROR, - { - message: - "Contact support@airqo.net -- unable to retrieve the default Group or Role to which the User will belong", - } - ); - } - - this.group_roles = [ - { - group: mongoose.Types.ObjectId(constants.DEFAULT_GROUP), - userType: "guest", - createdAt: new Date(), - role: mongoose.Types.ObjectId(constants.DEFAULT_GROUP_ROLE), - }, - ]; - } - - if (!this.verified) { - this.verified = false; - } - - if (!this.analyticsVersion) { - this.analyticsVersion = 2; - } - - return next(); -}); - -UserSchema.pre("update", function (next) { - if (this.isModified("password")) { - this.password = bcrypt.hashSync(this.password, saltRounds); - } - return next(); -}); - -UserSchema.index({ email: 1 }, { unique: true }); -UserSchema.index({ userName: 1 }, { unique: true }); - -UserSchema.statics = { - async register(args, next) { - try { - const data = await this.create({ - ...args, - }); - if (!isEmpty(data)) { - return { - success: true, - data, - message: "user created", - status: httpStatus.OK, - }; - } else if (isEmpty(data)) { - return { - success: true, - data, - message: "Operation successful but user NOT successfully created", - status: httpStatus.OK, - }; - } - } catch (err) { - logObject("the error", err); - let response = {}; - let message = "validation errors for some of the provided fields"; - let status = httpStatus.CONFLICT; - if (err.code === 11000) { - logObject("the err.code again", err.code); - const duplicate_record = args.email ? args.email : args.userName; - response[duplicate_record] = `${duplicate_record} must be unique`; - response["message"] = - "the email and userName must be unique for every user"; - try { - const email = args.email; - const firstName = args.firstName; - const lastName = args.lastName; - const emailResponse = await mailer.existingUserRegistrationRequest( - { - email, - firstName, - lastName, - }, - next - ); - if (emailResponse && emailResponse.success === false) { - logger.error( - `🐛🐛 Internal Server Error -- ${stringify(emailResponse)}` - ); - } - } catch (error) { - logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); - } - } else if (err.keyValue) { - Object.entries(err.keyValue).forEach(([key, value]) => { - return (response[key] = `the ${key} must be unique`); - }); - } else if (err.errors) { - Object.entries(err.errors).forEach(([key, value]) => { - return (response[key] = value.message); - }); - } - logger.error(`🐛🐛 Internal Server Error -- ${err.message}`); - next(new HttpError(message, status, response)); - } - }, - async listStatistics(next) { - try { - const response = await this.aggregate() - .match({ email: { $nin: [null, ""] } }) - .sort({ createdAt: -1 }) - .lookup({ - from: "clients", - localField: "_id", - foreignField: "user_id", - as: "clients", - }) - .lookup({ - from: "users", - localField: "clients.user_id", - foreignField: "_id", - as: "api_clients", - }) - .group({ - _id: null, - users: { $sum: 1 }, - user_details: { - $push: { - userName: "$userName", - email: "$email", - firstName: "$firstName", - lastName: "$lastName", - _id: "$_id", - }, - }, - active_users: { $sum: { $cond: ["$isActive", 1, 0] } }, - active_user_details: { - $addToSet: { - $cond: { - if: "$isActive", - then: { - userName: "$userName", - email: "$email", - firstName: "$firstName", - lastName: "$lastName", - _id: "$_id", - }, - else: "$nothing", - }, - }, - }, - client_users: { $addToSet: "$clients.user_id" }, - api_user_details: { - $addToSet: { - userName: { $arrayElemAt: ["$api_clients.userName", 0] }, - email: { $arrayElemAt: ["$api_clients.email", 0] }, - firstName: { $arrayElemAt: ["$api_clients.firstName", 0] }, - lastName: { $arrayElemAt: ["$api_clients.lastName", 0] }, - _id: { $arrayElemAt: ["$api_clients._id", 0] }, - }, - }, - }) - .project({ - _id: 0, - users: { - number: "$users", - details: "$user_details", - }, - active_users: { - number: "$active_users", - details: "$active_user_details", - }, - api_users: { - number: { $size: { $ifNull: ["$client_users", []] } }, - details: "$api_user_details", - }, - }) - .allowDiskUse(true); - - if (!isEmpty(response)) { - return { - success: true, - message: "Successfully retrieved the user statistics", - data: response[0], - status: httpStatus.OK, - }; - } else if (isEmpty(response)) { - return { - success: true, - message: "No users statistics exist", - data: [], - status: httpStatus.OK, - }; - } - } catch (error) { - logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); - next( - new HttpError( - "Internal Server Error", - httpStatus.INTERNAL_SERVER_ERROR, - { message: error.message } - ) - ); - } - }, - async list({ skip = 0, limit = 100, filter = {} } = {}, next) { - try { - const inclusionProjection = constants.USERS_INCLUSION_PROJECTION; - const exclusionProjection = constants.USERS_EXCLUSION_PROJECTION( - filter.category ? filter.category : "none" - ); - - if (!isEmpty(filter.category)) { - delete filter.category; - } - logObject("the filter being used", filter); - const response = await this.aggregate() - .match(filter) - .lookup({ - from: "permissions", - localField: "permissions", - foreignField: "_id", - as: "permissions", - }) - .lookup({ - from: "clients", - localField: "_id", - foreignField: "user_id", - as: "clients", - }) - .lookup({ - from: "networks", - localField: "_id", - foreignField: "net_manager", - as: "my_networks", - }) - .lookup({ - from: "groups", - localField: "_id", - foreignField: "grp_manager", - as: "my_groups", - }) - .addFields({ - createdAt: { - $dateToString: { - format: "%Y-%m-%d %H:%M:%S", - date: "$_id", - }, - }, - }) - .unwind({ - path: "$network_roles", - preserveNullAndEmptyArrays: true, - }) - .unwind({ - path: "$group_roles", - preserveNullAndEmptyArrays: true, - }) - .lookup({ - from: "networks", - localField: "network_roles.network", - foreignField: "_id", - as: "network", - }) - .lookup({ - from: "groups", - localField: "group_roles.group", - foreignField: "_id", - as: "group", - }) - .lookup({ - from: "roles", - localField: "network_roles.role", - foreignField: "_id", - as: "network_role", - }) - .lookup({ - from: "roles", - localField: "group_roles.role", - foreignField: "_id", - as: "group_role", - }) - .lookup({ - from: "permissions", - localField: "network_role.role_permissions", - foreignField: "_id", - as: "network_role_permissions", - }) - .lookup({ - from: "permissions", - localField: "group_role.role_permissions", - foreignField: "_id", - as: "group_role_permissions", - }) - .group({ - _id: "$_id", - firstName: { $first: "$firstName" }, - lastName: { $first: "$lastName" }, - lastLogin: { $first: "$lastLogin" }, - timezone: { $first: "$timezone" }, - isActive: { $first: "$isActive" }, - loginCount: { $first: "$loginCount" }, - userName: { $first: "$userName" }, - email: { $first: "$email" }, - verified: { $first: "$verified" }, - analyticsVersion: { $first: "$analyticsVersion" }, - country: { $first: "$country" }, - privilege: { $first: "$privilege" }, - website: { $first: "$website" }, - category: { $first: "$category" }, - organization: { $first: "$organization" }, - long_organization: { $first: "$long_organization" }, - rateLimit: { $first: "$rateLimit" }, - jobTitle: { $first: "$jobTitle" }, - description: { $first: "$description" }, - profilePicture: { $first: "$profilePicture" }, - phoneNumber: { $first: "$phoneNumber" }, - group_roles: { $first: "$group_roles" }, - network_roles: { $first: "$network_roles" }, - group_role: { $first: "$group_role" }, - network_role: { $first: "$network_role" }, - clients: { $first: "$clients" }, - groups: { - $addToSet: { - grp_title: { $arrayElemAt: ["$group.grp_title", 0] }, - _id: { $arrayElemAt: ["$group._id", 0] }, - createdAt: { $arrayElemAt: ["$group.createdAt", 0] }, - status: { $arrayElemAt: ["$group.grp_status", 0] }, - role: { - $cond: { - if: { $ifNull: ["$group_role", false] }, - then: { - _id: { $arrayElemAt: ["$group_role._id", 0] }, - role_name: { $arrayElemAt: ["$group_role.role_name", 0] }, - role_permissions: "$group_role_permissions", - }, - else: null, - }, - }, - userType: { - $cond: { - if: { $eq: [{ $type: "$group_roles.userType" }, "missing"] }, - then: "user", - else: "$group_roles.userType", - }, - }, - }, - }, - permissions: { $first: "$permissions" }, - my_networks: { $first: "$my_networks" }, - my_groups: { $first: "$my_groups" }, - createdAt: { $first: "$createdAt" }, - updatedAt: { $first: "$createdAt" }, - networks: { - $addToSet: { - net_name: { $arrayElemAt: ["$network.net_name", 0] }, - _id: { $arrayElemAt: ["$network._id", 0] }, - role: { - $cond: { - if: { $ifNull: ["$network_role", false] }, - then: { - _id: { $arrayElemAt: ["$network_role._id", 0] }, - role_name: { $arrayElemAt: ["$network_role.role_name", 0] }, - role_permissions: "$network_role_permissions", - }, - else: null, - }, - }, - userType: { - $cond: { - if: { - $eq: [{ $type: "$network_roles.userType" }, "missing"], - }, - then: "user", - else: "$network_roles.userType", - }, - }, - }, - }, - }) - .project(inclusionProjection) - .project(exclusionProjection) - .sort({ createdAt: -1 }) - .skip(skip ? skip : 0) - .limit(limit ? limit : parseInt(constants.DEFAULT_LIMIT)) - .allowDiskUse(true); - if (!isEmpty(response)) { - return { - success: true, - message: "successfully retrieved the user details", - data: response, - status: httpStatus.OK, - }; - } else if (isEmpty(response)) { - return { - success: true, - message: "no users exist", - data: [], - status: httpStatus.OK, - }; - } - } catch (error) { - logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); - next( - new HttpError( - "Internal Server Error", - httpStatus.INTERNAL_SERVER_ERROR, - { message: error.message } - ) - ); - } - }, - async modify({ filter = {}, update = {} } = {}, next) { - try { - logText("the user modification function........"); - let options = { new: true }; - const fieldNames = Object.keys(update); - const fieldsString = fieldNames.join(" "); - let modifiedUpdate = update; - modifiedUpdate["$addToSet"] = {}; - - if (update.password) { - modifiedUpdate.password = bcrypt.hashSync(update.password, saltRounds); - } - - if (modifiedUpdate.profilePicture) { - if (!validateProfilePicture(modifiedUpdate.profilePicture)) { - next( - new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { - message: "Invalid profile picture URL", - }) - ); - } - } - - if (modifiedUpdate.network_roles) { - if (isEmpty(modifiedUpdate.network_roles.network)) { - delete modifiedUpdate.network_roles; - } else { - modifiedUpdate["$addToSet"] = { - network_roles: { $each: modifiedUpdate.network_roles }, - }; - delete modifiedUpdate.network_roles; - } - } - - if (modifiedUpdate.group_roles) { - if (isEmpty(modifiedUpdate.group_roles.group)) { - delete modifiedUpdate.group_roles; - } else { - modifiedUpdate["$addToSet"] = { - group_roles: { $each: modifiedUpdate.group_roles }, - }; - delete modifiedUpdate.group_roles; - } - } - - if (modifiedUpdate.permissions) { - modifiedUpdate["$addToSet"]["permissions"] = {}; - modifiedUpdate["$addToSet"]["permissions"]["$each"] = - modifiedUpdate.permissions; - delete modifiedUpdate["permissions"]; - } - - const updatedUser = await this.findOneAndUpdate( - filter, - modifiedUpdate, - options - ).select(fieldsString); - - if (!isEmpty(updatedUser)) { - const { _id, ...userData } = updatedUser._doc; - return { - success: true, - message: "successfully modified the user", - data: userData, - status: httpStatus.OK, - }; - } else if (isEmpty(updatedUser)) { - next( - new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { - message: "user does not exist, please crosscheck", - }) - ); - } - } catch (error) { - logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); - next( - new HttpError( - "Internal Server Error", - httpStatus.INTERNAL_SERVER_ERROR, - { message: error.message } - ) - ); - } - }, - async remove({ filter = {} } = {}, next) { - try { - const options = { - projection: { - _id: 0, - email: 1, - firstName: 1, - lastName: 1, - lastLogin: 1, - }, - }; - const removedUser = await this.findOneAndRemove(filter, options).exec(); - - if (!isEmpty(removedUser)) { - return { - success: true, - message: "Successfully removed the user", - data: removedUser._doc, - status: httpStatus.OK, - }; - } else if (isEmpty(removedUser)) { - next( - new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { - message: "Provided User does not exist, please crosscheck", - }) - ); - } - } catch (error) { - logObject("the models error", error); - logger.error(`🐛🐛 Internal Server Error -- ${error.message}`); - next( - new HttpError( - "Internal Server Error", - httpStatus.INTERNAL_SERVER_ERROR, - { message: error.message } - ) - ); - } - }, -}; - -UserSchema.methods = { - authenticateUser(password) { - return bcrypt.compareSync(password, this.password); - }, - newToken() { - const token = accessCodeGenerator.generate( - constants.RANDOM_PASSWORD_CONFIGURATION(10) - ); - const hashedToken = bcrypt.hashSync(token, saltRounds); - return { - accessToken: hashedToken, - plainTextToken: `${token.id}|${plainTextToken}`, - }; - }, - async toAuthJSON() { - const token = await this.createToken(); - return { - _id: this._id, - userName: this.userName, - token: `JWT ${token}`, - email: this.email, - }; - }, - toJSON() { - return { - _id: this._id, - userName: this.userName, - email: this.email, - firstName: this.firstName, - organization: this.organization, - long_organization: this.long_organization, - group_roles: this.group_roles, - network_roles: this.network_roles, - privilege: this.privilege, - lastName: this.lastName, - country: this.country, - website: this.website, - category: this.category, - jobTitle: this.jobTitle, - profilePicture: this.profilePicture, - phoneNumber: this.phoneNumber, - description: this.description, - createdAt: this.createdAt, - updatedAt: this.updatedAt, - role: this.role, - verified: this.verified, - analyticsVersion: this.analyticsVersion, - rateLimit: this.rateLimit, - lastLogin: this.lastLogin, - isActive: this.isActive, - loginCount: this.loginCount, - timezone: this.timezone, - }; - }, -}; - -const UserModel = (tenant) => { - const defaultTenant = constants.DEFAULT_TENANT || "airqo"; - const dbTenant = isEmpty(tenant) ? defaultTenant : tenant; - try { - let users = mongoose.model("users"); - return users; - } catch (error) { - let users = getModelByTenant(dbTenant, "user", UserSchema); - return users; - } -}; -UserSchema.methods.createToken = async function () { - try { - const filter = { _id: this._id }; - const userWithDerivedAttributes = await UserModel("airqo").list({ filter }); - if ( - userWithDerivedAttributes.success && - userWithDerivedAttributes.success === false - ) { - logger.error( - `Internal Server Error -- ${JSON.stringify(userWithDerivedAttributes)}` - ); - return userWithDerivedAttributes; - } else { - const user = userWithDerivedAttributes.data[0]; - const oneDayExpiry = Math.floor(Date.now() / 1000) + 24 * 60 * 60; - const oneHourExpiry = Math.floor(Date.now() / 1000) + 60 * 60; - logObject("user", user); - return jwt.sign( - { - _id: user._id, - firstName: user.firstName, - lastName: user.lastName, - userName: user.userName, - email: user.email, - organization: user.organization, - long_organization: user.long_organization, - privilege: user.privilege, - role: user.role, - country: user.country, - profilePicture: user.profilePicture, - phoneNumber: user.phoneNumber, - createdAt: user.createdAt, - updatedAt: user.updatedAt, - rateLimit: user.rateLimit, - lastLogin: user.lastLogin, - // exp: oneHourExpiry, - }, - constants.JWT_SECRET - ); - } - } catch (error) { - logger.error(`🐛🐛 Internal Server Error --- ${error.message}`); - } -}; - -module.exports = UserModel; diff --git a/src/auth-service/models/log.js b/src/auth-service/models/log.js index fa857c67c5..83378df314 100644 --- a/src/auth-service/models/log.js +++ b/src/auth-service/models/log.js @@ -29,7 +29,17 @@ const logSchema = new mongoose.Schema( default: {}, }, }, - { timestamps: true } + { + timestamps: true, + indexes: [ + { + timestamp: 1, + "meta.email": 1, + "meta.service": 1, + "meta.endpoint": 1, + }, + ], + } ); logSchema.pre("save", function (next) { diff --git a/src/auth-service/routes/v2/analytics.js b/src/auth-service/routes/v2/analytics.js new file mode 100644 index 0000000000..e0413c5698 --- /dev/null +++ b/src/auth-service/routes/v2/analytics.js @@ -0,0 +1,155 @@ +const express = require("express"); +const router = express.Router(); +const createAnalyticsController = require("@controllers/create-analytics"); +const validateTenant = require("@middleware/validateTenant"); +const { + userEngagementValidators, + activityValidators, + cohortValidators, + predictiveValidators, + serviceAdoptionValidators, + benchmarkValidators, + topUsersValidators, + aggregateValidators, + retentionValidators, + healthScoreValidators, + behaviorValidators, + emailValidators, +} = require("@validators/analytics.validators"); + +router.get( + "/activities", + validateTenant(), + createAnalyticsController.listActivities +); + +router.get("/logs", validateTenant(), createAnalyticsController.listLogs); + +router.get( + "/user-stats", + validateTenant(), + createAnalyticsController.getUserStats +); + +router.get( + "/statistics", + validateTenant(), + createAnalyticsController.listStatistics +); + +// User Engagement Routes +router.get( + "/:email/engagement", + validateTenant(), + userEngagementValidators.getEngagement, + createAnalyticsController.getUserEngagement +); + +router.get( + "/:email/engagement/metrics", + validateTenant(), + userEngagementValidators.getMetrics, + createAnalyticsController.getEngagementMetrics +); + +// Activity Analysis Routes +router.get( + "/:email/activity-report", + validateTenant(), + activityValidators.getReport, + createAnalyticsController.getActivityReport +); + +// Cohort Analysis Routes +router.get( + "/cohorts", + validateTenant(), + cohortValidators.getAnalysis, + createAnalyticsController.getCohortAnalysis +); + +// Predictive Analytics Routes +router.get( + "/:email/predictions", + validateTenant(), + predictiveValidators.getPredictions, + createAnalyticsController.getPredictiveAnalytics +); + +// Service Adoption Routes +router.get( + "/:email/service-adoption", + validateTenant(), + serviceAdoptionValidators.getAdoption, + createAnalyticsController.getServiceAdoption +); + +// Benchmark Routes +router.get( + "/benchmarks", + validateTenant(), + benchmarkValidators.getBenchmarks, + createAnalyticsController.getBenchmarks +); + +// Top Users Routes +router.get( + "/top-users", + validateTenant(), + topUsersValidators.getTopUsers, + createAnalyticsController.getTopUsers +); + +// Aggregated Analytics Routes +router.get( + "/aggregate", + validateTenant(), + aggregateValidators.getAggregated, + createAnalyticsController.getAggregatedAnalytics +); + +// Retention Analysis Routes +router.get( + "/retention", + validateTenant(), + retentionValidators.getRetention, + createAnalyticsController.getRetentionAnalysis +); + +// Health Score Routes +router.get( + "/:email/health-score", + validateTenant(), + healthScoreValidators.getHealthScore, + createAnalyticsController.getEngagementHealth +); + +// Behavior Pattern Routes +router.get( + "/:email/behavior", + validateTenant(), + behaviorValidators.getBehavior, + createAnalyticsController.getBehaviorPatterns +); + +// Existing Email Routes +router.post( + "/send", + validateTenant(), + emailValidators.sendEmails, + createAnalyticsController.send +); + +router.post( + "/retrieve", + validateTenant(), + emailValidators.retrieveStats, + createAnalyticsController.fetchUserStats +); + +router.get( + "/validate-environment", + createAnalyticsController.validateEnvironment +); + +module.exports = router; diff --git a/src/auth-service/routes/v2/groups.js b/src/auth-service/routes/v2/groups.js index b9dcd08657..54fed09699 100644 --- a/src/auth-service/routes/v2/groups.js +++ b/src/auth-service/routes/v2/groups.js @@ -349,6 +349,53 @@ router.get( ]), createGroupController.listSummary ); + +router.put( + "/:grp_id/set-manager/:user_id", + oneOf([ + [ + query("tenant") + .optional() + .notEmpty() + .withMessage("tenant cannot be empty if provided") + .bail() + .trim() + .toLowerCase() + .isIn(["kcca", "airqo"]) + .withMessage("the tenant value is not among the expected ones"), + ], + ]), + oneOf([ + [ + param("grp_id") + .exists() + .withMessage("the group ID param is missing in the request") + .bail() + .trim() + .isMongoId() + .withMessage("the group ID must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + param("user_id") + .exists() + .withMessage("the user ID param is missing in the request") + .bail() + .trim() + .isMongoId() + .withMessage("the user ID must be an object ID") + .bail() + .customSanitizer((value) => { + return ObjectId(value); + }), + ], + ]), + setJWTAuth, + authJWT, + createGroupController.setManager +); + router.get( "/:grp_id/assigned-users", oneOf([ @@ -506,11 +553,11 @@ router.delete( [ param("grp_id") .exists() - .withMessage("the network ID is missing in request") + .withMessage("the group ID is missing in request") .bail() .trim() .isMongoId() - .withMessage("the network ID must be an object ID") + .withMessage("the group ID must be an object ID") .bail() .customSanitizer((value) => { return ObjectId(value); @@ -551,11 +598,11 @@ router.delete( [ param("grp_id") .exists() - .withMessage("the network ID is missing in request") + .withMessage("the group ID is missing in request") .bail() .trim() .isMongoId() - .withMessage("the network ID must be an object ID") + .withMessage("the group ID must be an object ID") .bail() .customSanitizer((value) => { return ObjectId(value); diff --git a/src/auth-service/routes/v2/index.js b/src/auth-service/routes/v2/index.js index a0cee14f1b..fb8c364358 100644 --- a/src/auth-service/routes/v2/index.js +++ b/src/auth-service/routes/v2/index.js @@ -6,6 +6,7 @@ router.use("/permissions", require("@routes/v2/permissions")); router.use("/favorites", require("@routes/v2/favorites")); router.use("/roles", require("@routes/v2/roles")); router.use("/inquiries", require("@routes/v2/inquiries")); +router.use("/analytics", require("@routes/v2/analytics")); router.use("/candidates", require("@routes/v2/candidates")); router.use("/requests", require("@routes/v2/requests")); router.use("/defaults", require("@routes/v2/defaults")); diff --git a/src/auth-service/routes/v2/users.js b/src/auth-service/routes/v2/users.js index 70198fe2d0..f7ef17d281 100644 --- a/src/auth-service/routes/v2/users.js +++ b/src/auth-service/routes/v2/users.js @@ -1131,7 +1131,7 @@ router.get( ); router.get( - "/analytics", + "/user-stats", oneOf([ query("tenant") .optional() diff --git a/src/auth-service/utils/create-analytics.js b/src/auth-service/utils/create-analytics.js new file mode 100644 index 0000000000..483acbf7ca --- /dev/null +++ b/src/auth-service/utils/create-analytics.js @@ -0,0 +1,2640 @@ +const constants = require("@config/constants"); +const { logObject, logText } = require("@utils/log"); +const mailer = require("@utils/mailer"); +const generateFilter = require("@utils/generate-filter"); +const { LogModel } = require("@models/log"); +const ActivityModel = require("@models/Activity"); +const UserModel = require("@models/User"); +const stringify = require("@utils/stringify"); +const log4js = require("log4js"); +const logger = log4js.getLogger( + `${constants.ENVIRONMENT} -- create-analytics-util` +); +const { HttpError } = require("@utils/errors"); +const httpStatus = require("http-status"); +const { + addMonthsToProvideDateTime, + monthsInfront, + isTimeEmpty, + getDifferenceInMonths, + addDays, +} = require("@utils/date"); + +const routesWithService = [ + { + method: "POST", + uriIncludes: [ + "api/v2/analytics/data-download", + "api/v1/analytics/data-download", + ], + service: "data-export-download", + action: "Export Data", + }, + { + method: "POST", + uriIncludes: [ + "api/v1/analytics/data-export", + "api/v2/analytics/data-export", + ], + service: "data-export-scheduling", + action: "Schedule Data Download", + }, + /**** Sites */ + { + method: "POST", + uriIncludes: ["/api/v2/devices/sites"], + service: "site-registry", + action: "Site Creation", + }, + { + method: "GET", + uriIncludes: ["/api/v2/devices/sites"], + service: "site-registry", + action: "View Sites", + }, + { + method: "PUT", + uriIncludes: ["/api/v2/devices/sites"], + service: "site-registry", + action: "Site Update", + }, + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/sites"], + service: "site-registry", + action: "Site Deletion", + }, + + /**** Devices */ + { + method: "DELETE", + uriIncludes: ["/api/v2/devices?"], + service: "device-registry", + action: "Device Deletion", + }, + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/soft?"], + service: "device-registry", + action: "Device SOFT Deletion", + }, + { + method: "PUT", + uriIncludes: ["/api/v2/devices?"], + service: "device-registry", + action: "Device Update", + }, + { + method: "PUT", + uriIncludes: ["/api/v2/devices/soft?"], + service: "device-registry", + action: "Device SOFT Update", + }, + { + method: "GET", + uriIncludes: ["/api/v2/devices?"], + service: "device-registry", + action: "View Devices", + }, + { + method: "POST", + uriIncludes: ["/api/v2/devices?"], + service: "device-registry", + action: "Device Creation", + }, + { + method: "POST", + uriIncludes: ["/api/v2/devices/soft?"], + service: "device-registry", + action: "Device SOFT Creation", + }, + /**** Cohorts */ + { + method: "GET", + uriIncludes: ["/api/v2/devices/cohorts"], + service: "cohort-registry", + action: "View Cohorts", + }, + + { + method: "POST", + uriIncludes: ["/api/v2/devices/cohorts"], + service: "cohort-registry", + action: "Create Cohorts", + }, + + { + method: "PUT", + uriIncludes: ["/api/v2/devices/cohorts"], + service: "cohort-registry", + action: "Update Cohort", + }, + + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/cohorts"], + service: "cohort-registry", + action: "Delete Cohort", + }, + + /**** Grids */ + + { + method: "GET", + uriIncludes: ["/api/v2/devices/grids"], + service: "grid-registry", + action: "View Grids", + }, + + { + method: "PUT", + uriIncludes: ["/api/v2/devices/grids"], + service: "grid-registry", + action: "Update Grid", + }, + + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/grids"], + service: "grid-registry", + action: "Delete Grid", + }, + + { + method: "POST", + uriIncludes: ["/api/v2/devices/grids"], + service: "grid-registry", + action: "Create Grid", + }, + + /**** AirQlouds */ + + { + method: "GET", + uriIncludes: ["/api/v2/devices/airqlouds"], + service: "airqloud-registry", + action: "View AirQlouds", + }, + { + method: "POST", + uriIncludes: ["/api/v2/devices/airqlouds"], + service: "airqloud-registry", + action: "AirQloud Creation", + }, + { + method: "PUT", + uriIncludes: ["/api/v2/devices/airqlouds"], + service: "airqloud-registry", + action: "AirQloud Update", + }, + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/airqlouds"], + service: "airqloud-registry", + action: "AirQloud Deletion", + }, + + /**** Site Activities */ + + { + method: "POST", + uriIncludes: ["/api/v2/devices/activities/maintain"], + service: "device-maintenance", + action: "Maintain Device", + }, + { + method: "POST", + uriIncludes: ["/api/v2/devices/activities/recall"], + service: "device-recall", + action: "Recall Device", + }, + { + method: "POST", + uriIncludes: ["/api/v2/devices/activities/deploy"], + service: "device-deployment", + action: "Deploy Device", + }, + + /**** Users */ + { + method: "POST", + uriIncludes: ["api/v2/users", "api/v1/users"], + service: "auth", + action: "Create User", + }, + { + method: "GET", + uriIncludes: ["api/v2/users", "api/v1/users"], + service: "auth", + action: "View Users", + }, + { + method: "PUT", + uriIncludes: ["api/v2/users", "api/v1/users"], + service: "auth", + action: "Update User", + }, + { + method: "DELETE", + uriIncludes: ["api/v2/users", "api/v1/users"], + service: "auth", + action: "Delete User", + }, + + /****Incentives*/ + { + method: "POST", + uriIncludes: [ + "api/v1/incentives/transactions/accounts/payments", + "api/v2/incentives/transactions/accounts/payments", + ], + service: "incentives", + action: "Add Money to Organizational Account", + }, + { + method: "POST", + uriIncludes: [ + "api/v1/incentives/transactions/hosts", + "api/v2/incentives/transactions/hosts", + ], + service: "incentives", + action: "Send Money to Host", + }, + + /**** Calibrate */ + { + method: "POST", + uriIncludes: ["/api/v1/calibrate", "/api/v2/calibrate"], + service: "calibrate", + action: "calibrate device", + }, + + /**** Locate */ + { + method: "POST", + uriIncludes: ["/api/v1/locate", "/api/v2/locate"], + service: "locate", + action: "Identify Suitable Device Locations", + }, + + /**** Fault Detection */ + { + method: "POST", + uriIncludes: ["/api/v1/predict-faults", "/api/v2/predict-faults"], + service: "fault-detection", + action: "Detect Faults", + }, + + /**** Readings... */ + { + method: "GET", + uriIncludes: [ + "/api/v2/devices/measurements", + "/api/v2/devices/events", + "/api/v2/devices/readings", + ], + service: "events-registry", + action: " Retrieve Measurements", + }, + + /**** Data Proxy */ + { + method: "GET", + uriIncludes: ["/api/v2/data"], + service: "data-mgt", + action: "Retrieve Data", + }, + { + method: "GET", + uriIncludes: ["/api/v2/data-proxy"], + service: "data-proxy", + action: "Retrieve Data", + }, + + /*****Analytics */ + { + method: "GET", + uriIncludes: ["/api/v2/analytics/dashboard/sites"], + service: "analytics", + action: "Retrieve Sites on Analytics Page", + }, + { + method: "GET", + uriIncludes: ["/api/v2/analytics/dashboard/historical/daily-averages"], + service: "analytics", + action: "Retrieve Daily Averages on Analytics Page", + }, + { + method: "GET", + uriIncludes: ["/api/v2/analytics/dashboard/exceedances-devices"], + service: "analytics", + action: "Retrieve Exceedances on Analytics Page", + }, + + /*****KYA lessons */ + + { + method: "GET", + uriIncludes: ["/api/v2/devices/kya/lessons/users"], + service: "kya", + action: "Retrieve KYA lessons", + }, + { + method: "POST", + uriIncludes: ["/api/v2/devices/kya/lessons/users"], + service: "kya", + action: "Create KYA lesson", + }, + { + method: "PUT", + uriIncludes: ["/api/v2/devices/kya/lessons/users"], + service: "kya", + action: "Update KYA lesson", + }, + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/kya/lessons/users"], + service: "kya", + action: "Delete KYA lesson", + }, + /*****KYA Quizzes */ + { + method: "GET", + uriIncludes: ["/api/v2/devices/kya/quizzes/users"], + service: "kya", + action: "Retrieve KYA quizzes", + }, + + { + method: "POST", + uriIncludes: ["/api/v2/devices/kya/quizzes"], + service: "kya", + action: "Create KYA quizzes", + }, + + { + method: "PUT", + uriIncludes: ["/api/v2/devices/kya/quizzes"], + service: "kya", + action: "Update KYA quiz", + }, + + { + method: "DELETE", + uriIncludes: ["/api/v2/devices/kya/quizzes"], + service: "kya", + action: "Delete KYA quiz", + }, + + /*****view */ + { + method: "GET", + uriIncludes: ["/api/v2/view/mobile-app/version-info"], + service: "mobile-version", + action: "View Mobile App Information", + }, + + /*****Predict */ + { + method: "GET", + uriIncludes: ["/api/v2/predict/daily-forecast"], + service: "predict", + action: "Retrieve Daily Forecasts", + }, + { + method: "GET", + uriIncludes: ["/api/v2/predict/hourly-forecast"], + service: "predict", + action: "Retrieve Hourly Forecasts", + }, + { + method: "GET", + uriIncludes: ["/api/v2/predict/heatmap"], + service: "predict", + action: "Retrieve Heatmap", + }, + + /*****Device Monitoring */ + { + method: "GET", + uriIncludes: ["/api/v2/monitor"], + service: "monitor", + action: "Retrieve Network Statistics Data", + }, + + { + method: "GET", + uriIncludes: ["/api/v2/meta-data"], + service: "meta-data", + action: "Retrieve Metadata", + }, + + { + method: "GET", + uriIncludes: ["/api/v2/network-uptime"], + service: "network-uptime", + action: "Retrieve Network Uptime Data", + }, +]; + +// Helper functions to calculate additional metrics +function calculateActivityDuration( + firstActivity, + lastActivity, + yearStart, + yearEnd +) { + // Ensure we're working with Date objects + const start = new Date(firstActivity); + const end = new Date(lastActivity); + const yearStartDate = new Date(yearStart); + const yearEndDate = new Date(yearEnd); + + // Use the year boundaries if dates fall outside + const effectiveStart = start < yearStartDate ? yearStartDate : start; + const effectiveEnd = end > yearEndDate ? yearEndDate : end; + + // Calculate duration + const duration = effectiveEnd - effectiveStart; + const days = Math.floor(duration / (1000 * 60 * 60 * 24)); + + // Calculate months more accurately + const months = Math.floor( + (effectiveEnd.getFullYear() - effectiveStart.getFullYear()) * 12 + + (effectiveEnd.getMonth() - effectiveStart.getMonth()) + ); + + return { + totalDays: days, + totalMonths: months, + yearStart: yearStartDate, + yearEnd: yearEndDate, + actualStart: effectiveStart, + actualEnd: effectiveEnd, + description: + months > 0 + ? `Active for ${months} month${months !== 1 ? "s" : ""}` + : `Active for ${days} day${days !== 1 ? "s" : ""}`, + }; +} + +// Helper function for calculating engagement score +function calculateEngagementScore({ + totalActions, + uniqueServices, + uniqueEndpoints, + activityDays, +}) { + // Use at least 30 days as denominator + const actionsPerDay = totalActions / Math.max(activityDays, 30); + + const serviceDiversity = + uniqueServices / Math.min(routesWithService.length, 20); + const endpointDiversity = Math.min(uniqueEndpoints / 10, 1); + + return ( + (actionsPerDay * 0.4 + serviceDiversity * 0.3 + endpointDiversity * 0.3) * + 100 + ); +} + +// Simplified engagement tier calculation +function calculateEngagementTier(score) { + if (score >= 80) return "Elite User"; + if (score >= 65) return "Super User"; + if (score >= 45) return "High Engagement"; + if (score >= 25) return "Moderate Engagement"; + return "Low Engagement"; +} + +/** + * Calculates user engagement metrics and analytics + * @param {Object} params Engagement calculation parameters + * @param {number} params.totalActions Total number of API calls/actions + * @param {Array} params.services List of unique services used + * @param {Array} params.endpoints List of unique endpoints accessed + * @param {Date} params.firstActivity Date of first activity + * @param {Date} params.lastActivity Date of last activity + * @param {Array} params.dailyStats Array of daily activity statistics + * @returns {Object} Comprehensive engagement metrics + */ +async function calculateUserEngagementMetrics({ + totalActions = 0, + services = [], + endpoints = [], + firstActivity, + lastActivity, + dailyStats = [], +}) { + const TOTAL_AVAILABLE_SERVICES = constants.AVAILABLE_SERVICES?.length || 20; + const TOTAL_AVAILABLE_ENDPOINTS = constants.AVAILABLE_ENDPOINTS?.length || 50; + + // Calculate base metrics + const activityDuration = + lastActivity && firstActivity + ? Math.ceil((lastActivity - firstActivity) / (1000 * 60 * 60 * 24)) + : 1; + + const actionsPerDay = totalActions / Math.max(activityDuration, 30); + + const uniqueServices = new Set(services.map((s) => s.name)); + const uniqueEndpoints = new Set(endpoints.map((e) => e.name)); + + const serviceDiversity = Math.min( + uniqueServices.size / TOTAL_AVAILABLE_SERVICES, + 1 + ); + const endpointDiversity = Math.min( + uniqueEndpoints.size / TOTAL_AVAILABLE_ENDPOINTS, + 1 + ); + + // Calculate activity patterns + const activityPatterns = analyzeActivityPatterns(dailyStats); + + // Calculate service usage patterns + const serviceUsagePatterns = analyzeServiceUsagePatterns(services); + + // Calculate consistency score + const consistencyScore = calculateConsistencyScore( + dailyStats, + activityDuration + ); + + // Calculate growth trend + const growthTrend = calculateGrowthTrend(dailyStats); + + // Calculate engagement score with enhanced weights + const engagementScore = calculateWeightedScore({ + actionsPerDay, + serviceDiversity, + endpointDiversity, + consistencyScore, + growthTrend, + }); + + return { + basicMetrics: { + totalActions, + activityDuration, + actionsPerDay, + uniqueServicesCount: uniqueServices.size, + uniqueEndpointsCount: uniqueEndpoints.size, + }, + engagementMetrics: { + serviceDiversity, + endpointDiversity, + consistencyScore, + growthTrend, + engagementScore, + }, + activityPatterns, + serviceUsagePatterns, + recommendations: generateRecommendations({ + serviceDiversity, + endpointDiversity, + consistencyScore, + serviceUsagePatterns, + }), + }; +} + +/** + * Analyzes daily activity patterns + * @param {Array} dailyStats Array of daily activity records + * @returns {Object} Activity pattern analysis + */ +function analyzeActivityPatterns(dailyStats) { + const activeWeekdays = new Set(); + const hourlyDistribution = new Array(24).fill(0); + const weeklyAverage = []; + + dailyStats.forEach((day) => { + const date = new Date(day.date); + activeWeekdays.add(date.getDay()); + + // Analyze hourly patterns if available + if (day.hourlyBreakdown) { + day.hourlyBreakdown.forEach((count, hour) => { + hourlyDistribution[hour] += count; + }); + } + + // Calculate weekly averages + if (weeklyAverage.length < 12) { + // Track last 12 weeks + weeklyAverage.push(day.totalActions); + } + }); + + return { + preferredDays: Array.from(activeWeekdays), + peakHours: findPeakHours(hourlyDistribution), + weeklyTrend: calculateWeeklyTrend(weeklyAverage), + consistency: calculateActivityConsistency(dailyStats), + }; +} + +/** + * Analyzes service usage patterns + * @param {Array} services List of services with usage counts + * @returns {Object} Service usage analysis + */ +function analyzeServiceUsagePatterns(services) { + const serviceUsage = services.reduce((acc, service) => { + acc[service.name] = acc[service.name] || { count: 0, frequency: 0 }; + acc[service.name].count += service.count; + acc[service.name].frequency++; + return acc; + }, {}); + + // Calculate primary and secondary services + const sortedServices = Object.entries(serviceUsage).sort( + ([, a], [, b]) => b.count - a.count + ); + + return { + primaryServices: sortedServices.slice(0, 3).map(([name]) => name), + serviceDistribution: sortedServices.reduce((acc, [name, data]) => { + acc[name] = (data.count / services.length) * 100; + return acc; + }, {}), + unusedServices: findUnusedServices(services), + }; +} + +/** + * Calculates consistency score based on activity patterns + * @param {Array} dailyStats Daily activity statistics + * @param {number} duration Total duration in days + * @returns {number} Consistency score between 0-1 + */ +function calculateConsistencyScore(dailyStats, duration) { + if (!dailyStats.length) return 0; + + const activeDaysCount = dailyStats.length; + const activeRatio = activeDaysCount / duration; + + // Calculate variance in daily activity + const avgActions = + dailyStats.reduce((sum, day) => sum + day.totalActions, 0) / + activeDaysCount; + const variance = + dailyStats.reduce((sum, day) => { + return sum + Math.pow(day.totalActions - avgActions, 2); + }, 0) / activeDaysCount; + + const consistencyFactor = 1 / (1 + Math.sqrt(variance) / avgActions); + + return activeRatio * 0.6 + consistencyFactor * 0.4; +} + +/** + * Calculates growth trend based on activity history + * @param {Array} dailyStats Daily activity statistics + * @returns {number} Growth trend indicator between -1 and 1 + */ +function calculateGrowthTrend(dailyStats) { + if (dailyStats.length < 7) return 0; + + const weeklyTotals = []; + let currentWeek = []; + + dailyStats.forEach((day) => { + currentWeek.push(day.totalActions); + if (currentWeek.length === 7) { + weeklyTotals.push(currentWeek.reduce((a, b) => a + b, 0)); + currentWeek = []; + } + }); + + if (weeklyTotals.length < 2) return 0; + + const growth = + weeklyTotals.slice(1).reduce((acc, curr, idx) => { + return acc + (curr - weeklyTotals[idx]) / weeklyTotals[idx]; + }, 0) / + (weeklyTotals.length - 1); + + return Math.max(-1, Math.min(1, growth)); +} + +/** + * Generates personalized recommendations based on usage patterns + * @param {Object} params User engagement metrics + * @returns {Array} List of recommendations + */ +function generateRecommendations({ + serviceDiversity, + endpointDiversity, + consistencyScore, + serviceUsagePatterns, +}) { + const recommendations = []; + + if (serviceDiversity < 0.3) { + recommendations.push({ + type: "service_exploration", + priority: "high", + message: + "Consider exploring additional services to enhance your integration", + suggestedServices: serviceUsagePatterns.unusedServices.slice(0, 3), + }); + } + + if (endpointDiversity < 0.3) { + recommendations.push({ + type: "endpoint_utilization", + priority: "medium", + message: "Explore additional endpoints within your current services", + }); + } + + if (consistencyScore < 0.5) { + recommendations.push({ + type: "consistency", + priority: "high", + message: "Consider establishing a more regular usage pattern", + }); + } + + return recommendations; +} + +/** + * Determines user engagement tier with enhanced criteria + * @param {Object} params Engagement tier calculation parameters + * @param {Object} params.engagementMetrics Complete engagement metrics + * @param {Date} params.lastActivity Date of last activity + * @returns {Object} Detailed engagement classification + */ +async function determineUserEngagementTier({ + engagementMetrics, + lastActivity, +}) { + const { engagementScore, consistencyScore, growthTrend } = engagementMetrics; + + const daysSinceLastActivity = lastActivity + ? Math.ceil((new Date() - lastActivity) / (1000 * 60 * 60 * 24)) + : Infinity; + + // Define tier criteria + const tierCriteria = { + Elite: { + minScore: 80, + minConsistency: 0.8, + minGrowth: 0.1, + }, + Super: { + minScore: 65, + minConsistency: 0.6, + minGrowth: 0, + }, + High: { + minScore: 45, + minConsistency: 0.4, + minGrowth: -0.1, + }, + Moderate: { + minScore: 25, + minConsistency: 0.2, + minGrowth: -0.2, + }, + }; + + // Return inactive status if no activity in last 90 days + if (daysSinceLastActivity > 90) { + return { + tier: "Inactive", + status: "dormant", + reactivationPriority: daysSinceLastActivity > 180 ? "high" : "medium", + }; + } + + // Determine tier based on multiple criteria + for (const [tier, criteria] of Object.entries(tierCriteria)) { + if ( + engagementScore >= criteria.minScore && + consistencyScore >= criteria.minConsistency && + growthTrend >= criteria.minGrowth + ) { + return { + tier: `${tier} User`, + status: "active", + metrics: { + score: engagementScore, + consistency: consistencyScore, + growth: growthTrend, + }, + nextTierProgress: calculateNextTierProgress( + engagementMetrics, + tier, + tierCriteria + ), + }; + } + } + + return { + tier: "Low Engagement", + status: "at_risk", + metrics: { + score: engagementScore, + consistency: consistencyScore, + growth: growthTrend, + }, + improvementAreas: identifyImprovementAreas(engagementMetrics), + }; +} + +/** + * Calculates progress towards next engagement tier + * @param {Object} metrics Current engagement metrics + * @param {string} currentTier Current tier + * @param {Object} tierCriteria Tier qualification criteria + * @returns {Object} Progress metrics towards next tier + */ +function calculateNextTierProgress(metrics, currentTier, tierCriteria) { + const tiers = Object.keys(tierCriteria); + const currentTierIndex = tiers.indexOf(currentTier); + + if (currentTierIndex === 0) return null; // Already at highest tier + + const nextTier = tiers[currentTierIndex - 1]; + const nextTierCriteria = tierCriteria[nextTier]; + + return { + nextTier, + scoreProgress: (metrics.engagementScore / nextTierCriteria.minScore) * 100, + consistencyProgress: + (metrics.consistencyScore / nextTierCriteria.minConsistency) * 100, + growthProgress: + metrics.growthTrend > 0 + ? (metrics.growthTrend / nextTierCriteria.minGrowth) * 100 + : 0, + }; +} + +function capitalizeWord(word) { + return word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(); +} + +function formatServiceName(serviceName) { + // Handle null, undefined or empty strings + if (!serviceName) return ""; + + // Replace hyphens with spaces and split into words + return serviceName.split("-").map(capitalizeWord).join(" "); +} + +// Get a user's monthly stats for a specific period +const getUserMonthlyStats = async (email, year, month, tenant = "airqo") => { + const stats = await ActivityModel(tenant).findOne( + { + email, + "monthlyStats.year": year, + "monthlyStats.month": month, + }, + { + "monthlyStats.$": 1, + overallStats: 1, + } + ); + return stats; +}; + +// Get a user's daily stats for a date range +const getUserDailyStats = async ( + email, + startDate, + endDate, + tenant = "airqo" +) => { + const stats = await ActivityModel(tenant).findOne( + { + email, + "dailyStats.date": { + $gte: startDate, + $lte: endDate, + }, + }, + { + dailyStats: { + $filter: { + input: "$dailyStats", + as: "day", + cond: { + $and: [ + { $gte: ["$$day.date", startDate] }, + { $lte: ["$$day.date", endDate] }, + ], + }, + }, + }, + } + ); + return stats; +}; + +// Get top users by engagement score for a specific month +const getTopUsers = async (year, month, limit = 10, tenant = "airqo") => { + const users = await ActivityModel(tenant) + .find({ + "monthlyStats.year": year, + "monthlyStats.month": month, + }) + .sort({ "monthlyStats.$.engagementScore": -1 }) + .limit(limit); + return users; +}; + +/** + * Predicts future engagement trends and potential churn risk + * @param {Object} params Prediction parameters + * @param {Array} params.dailyStats Historical daily statistics + * @param {Array} params.monthlyStats Monthly statistics + * @param {Object} params.currentMetrics Current engagement metrics + * @returns {Object} Prediction analysis and risk assessment + */ +async function predictEngagementTrends({ + dailyStats = [], + monthlyStats = [], + currentMetrics = {}, +}) { + // Calculate trailing indicators + const trailingIndicators = calculateTrailingIndicators(dailyStats); + + // Analyze seasonal patterns + const seasonalPatterns = analyzeSeasonalPatterns(monthlyStats); + + // Predict future engagement + const predictions = generateEngagementPredictions({ + trailingIndicators, + seasonalPatterns, + currentMetrics, + }); + + // Calculate churn risk + const churnRisk = assessChurnRisk({ + trailingIndicators, + predictions, + currentMetrics, + }); + + return { + shortTermPredictions: predictions.shortTerm, + longTermPredictions: predictions.longTerm, + churnRisk, + seasonalPatterns, + recommendedActions: generatePreventiveActions(churnRisk), + }; +} + +/** + * Performs cohort analysis for user segments + * @param {Array} users Array of user activities + * @param {Object} params Analysis parameters + * @returns {Object} Cohort analysis results + */ +async function performCohortAnalysis( + users, + { + cohortPeriod = "monthly", + metrics = ["engagement", "retention", "activity"], + minCohortSize = 5, + } = {} +) { + // Group users into cohorts + const cohorts = groupIntoCohorts(users, cohortPeriod); + + // Calculate cohort metrics + const cohortMetrics = calculateCohortMetrics(cohorts, metrics); + + // Analyze cohort patterns + const patterns = analyzeCohortPatterns(cohortMetrics); + + return { + cohortMetrics, + patterns, + insights: generateCohortInsights(patterns), + benchmarks: calculateCohortBenchmarks(cohortMetrics), + }; +} + +/** + * Performs competitive benchmarking against platform averages + * @param {Object} userMetrics Individual user metrics + * @param {Object} benchmarkData Platform benchmark data + * @returns {Object} Benchmark comparison results + */ +function performBenchmarkAnalysis(userMetrics, benchmarkData) { + const comparisons = {}; + const insights = []; + + // Compare key metrics against benchmarks + Object.entries(userMetrics).forEach(([metric, value]) => { + if (benchmarkData[metric]) { + const percentile = calculatePercentile( + value, + benchmarkData[metric].distribution + ); + const comparison = { + value, + benchmark: benchmarkData[metric].average, + percentile, + difference: + ((value - benchmarkData[metric].average) / + benchmarkData[metric].average) * + 100, + }; + comparisons[metric] = comparison; + + // Generate insights based on significant differences + if (Math.abs(comparison.difference) > 20) { + insights.push(generateBenchmarkInsight(metric, comparison)); + } + } + }); + + return { comparisons, insights }; +} + +/** + * Generates engagement health score and detailed diagnostics + * @param {Object} params Health score parameters + * @returns {Object} Health score and diagnostics + */ +function calculateEngagementHealth({ + currentMetrics, + historicalData, + benchmarks, + predictions, +}) { + // Calculate core health indicators + const indicators = { + activity: calculateActivityHealth(currentMetrics, historicalData), + growth: calculateGrowthHealth(currentMetrics, predictions), + sustainability: calculateSustainabilityHealth(currentMetrics, benchmarks), + diversity: calculateDiversityHealth(currentMetrics), + }; + + // Generate overall health score + const healthScore = calculateOverallHealth(indicators); + + // Identify health factors + const { strengths, weaknesses } = identifyHealthFactors(indicators); + + return { + healthScore, + indicators, + diagnosis: { + strengths, + weaknesses, + prognosis: generateHealthPrognosis(healthScore, predictions), + }, + recommendations: generateHealthRecommendations(indicators), + }; +} + +/** + * Analyzes user behavior patterns and segments + * @param {Array} activityData User activity data + * @returns {Object} Behavior analysis results + */ +function analyzeBehaviorPatterns(activityData) { + // Identify usage patterns + const patterns = identifyUsagePatterns(activityData); + + // Segment user behavior + const segments = segmentUserBehavior(patterns); + + // Analyze feature adoption + const featureAdoption = analyzeFeatureAdoption(activityData); + + return { + patterns, + segments, + featureAdoption, + recommendations: generateBehaviorRecommendations(segments), + }; +} + +/** + * Generates detailed activity analytics report + * @param {Object} params Report parameters + * @returns {Object} Comprehensive activity report + */ +async function generateActivityReport({ + userId, + timeframe = "last30days", + metrics = ["all"], + includeProjections = true, +}) { + // Gather all required data + const activityData = await aggregateActivityData(userId, timeframe); + const benchmarks = await getBenchmarkData(timeframe); + + // Calculate current metrics + const currentMetrics = await calculateUserEngagementMetrics(activityData); + + // Generate predictions if requested + const predictions = includeProjections + ? await predictEngagementTrends({ + dailyStats: activityData.dailyStats, + monthlyStats: activityData.monthlyStats, + currentMetrics, + }) + : null; + + // Calculate engagement health + const healthAnalysis = calculateEngagementHealth({ + currentMetrics, + historicalData: activityData, + benchmarks, + predictions, + }); + + // Analyze behavior patterns + const behaviorAnalysis = analyzeBehaviorPatterns(activityData); + + return { + summary: { + userId, + timeframe, + healthScore: healthAnalysis.healthScore, + engagementTier: await determineUserEngagementTier({ + engagementMetrics: currentMetrics, + lastActivity: activityData.lastActivity, + }), + }, + metrics: currentMetrics, + health: healthAnalysis, + behavior: behaviorAnalysis, + predictions: predictions, + benchmarks: await performBenchmarkAnalysis(currentMetrics, benchmarks), + recommendations: prioritizeRecommendations([ + ...healthAnalysis.recommendations, + ...behaviorAnalysis.recommendations, + ]), + }; +} + +/** + * Analyzes service adoption and usage maturity + * @param {Object} params Service analysis parameters + * @returns {Object} Service adoption analysis + */ +function analyzeServiceAdoption({ services, endpoints, historicalUsage }) { + // Calculate adoption metrics + const adoptionMetrics = calculateAdoptionMetrics(services, endpoints); + + // Analyze usage maturity + const maturityAnalysis = assessUsageMaturity(historicalUsage); + + // Identify adoption barriers + const barriers = identifyAdoptionBarriers(adoptionMetrics, maturityAnalysis); + + return { + adoptionMetrics, + maturityLevel: maturityAnalysis.level, + barriers, + recommendations: generateAdoptionRecommendations(barriers), + }; +} + +// Helper functions implementation + +/** + * Calculates trailing indicators from daily statistics + * @param {Array} dailyStats Array of daily activity records + * @returns {Object} Trailing indicators and trends + */ +function calculateTrailingIndicators(dailyStats) { + // Sort dailyStats by date + const sortedStats = [...dailyStats].sort( + (a, b) => new Date(a.date) - new Date(b.date) + ); + + // Calculate trailing windows + const windows = { + "7d": sortedStats.slice(-7), + "30d": sortedStats.slice(-30), + "90d": sortedStats.slice(-90), + }; + + // Calculate metrics for each window + const indicators = {}; + Object.entries(windows).forEach(([period, stats]) => { + const totalActions = stats.reduce((sum, day) => sum + day.totalActions, 0); + const avgActions = totalActions / stats.length; + const variance = + stats.reduce((sum, day) => { + return sum + Math.pow(day.totalActions - avgActions, 2); + }, 0) / stats.length; + + indicators[period] = { + totalActions, + avgActions, + variance, + volatility: Math.sqrt(variance) / avgActions, + trend: calculateTrend(stats.map((day) => day.totalActions)), + }; + }); + + return { + indicators, + momentum: calculateMomentum(indicators), + stability: assessStability(indicators), + }; +} + +/** + * Analyzes seasonal patterns in monthly statistics + * @param {Array} monthlyStats Array of monthly statistics + * @returns {Object} Seasonal patterns and trends + */ +function analyzeSeasonalPatterns(monthlyStats) { + const patterns = { + monthly: {}, + quarterly: {}, + yearly: {}, + }; + + // Analyze monthly patterns + for (let month = 0; month < 12; month++) { + const monthData = monthlyStats.filter( + (stat) => new Date(stat.date).getMonth() === month + ); + patterns.monthly[month] = calculateMonthlyPattern(monthData); + } + + // Analyze quarterly patterns + for (let quarter = 0; quarter < 4; quarter++) { + const quarterData = monthlyStats.filter( + (stat) => Math.floor(new Date(stat.date).getMonth() / 3) === quarter + ); + patterns.quarterly[quarter] = calculateQuarterlyPattern(quarterData); + } + + // Calculate year-over-year trends + patterns.yearly = calculateYearlyPatterns(monthlyStats); + + return { + patterns, + seasonalityIndex: calculateSeasonalityIndex(patterns), + peakPeriods: identifyPeakPeriods(patterns), + troughPeriods: identifyTroughPeriods(patterns), + }; +} + +/** + * Generates engagement predictions based on historical data + * @param {Object} params Prediction parameters + * @returns {Object} Short and long-term predictions + */ +function generateEngagementPredictions({ + trailingIndicators, + seasonalPatterns, + currentMetrics, +}) { + const shortTermPrediction = generateShortTermPrediction({ + trailingIndicators, + currentMetrics, + }); + + const longTermPrediction = generateLongTermPrediction({ + seasonalPatterns, + currentMetrics, + }); + + const confidenceScores = calculatePredictionConfidence({ + shortTermPrediction, + longTermPrediction, + historicalAccuracy: trailingIndicators, + }); + + return { + shortTerm: { + ...shortTermPrediction, + confidence: confidenceScores.shortTerm, + }, + longTerm: { + ...longTermPrediction, + confidence: confidenceScores.longTerm, + }, + factors: identifyPredictionFactors({ + trailingIndicators, + seasonalPatterns, + }), + }; +} + +/** + * Assesses risk of user churn based on engagement metrics + * @param {Object} params Risk assessment parameters + * @returns {Object} Churn risk analysis + */ +function assessChurnRisk({ trailingIndicators, predictions, currentMetrics }) { + // Calculate risk factors + const riskFactors = { + activityDecline: calculateActivityDecline(trailingIndicators), + engagementDrop: calculateEngagementDrop(currentMetrics), + predictedChurn: evaluatePredictedChurn(predictions), + }; + + // Calculate risk scores + const riskScores = { + immediate: calculateImmediateRisk(riskFactors), + shortTerm: calculateShortTermRisk(riskFactors), + longTerm: calculateLongTermRisk(riskFactors), + }; + + return { + riskLevel: determineRiskLevel(riskScores), + riskFactors, + riskScores, + timeToChurn: estimateTimeToChurn(riskScores, trailingIndicators), + }; +} + +/** + * Generates preventive actions based on churn risk analysis + * @param {Object} churnRisk Churn risk analysis results + * @returns {Array} Prioritized preventive actions + */ +function generatePreventiveActions(churnRisk) { + const actions = []; + + // Generate immediate actions for high-risk users + if (churnRisk.riskLevel === "high") { + actions.push(...generateHighRiskActions(churnRisk)); + } + + // Generate proactive actions for medium-risk users + if (churnRisk.riskLevel === "medium") { + actions.push(...generateMediumRiskActions(churnRisk)); + } + + // Generate monitoring actions for low-risk users + if (churnRisk.riskLevel === "low") { + actions.push(...generateLowRiskActions(churnRisk)); + } + + return prioritizeActions(actions, churnRisk); +} + +/** + * Groups users into cohorts based on specified period + * @param {Array} users Array of user records + * @param {string} period Cohort period ('daily', 'weekly', 'monthly') + * @returns {Object} Grouped cohorts + */ +function groupIntoCohorts(users, period) { + const cohorts = {}; + + users.forEach((user) => { + const cohortKey = generateCohortKey(user.firstActivity, period); + cohorts[cohortKey] = cohorts[cohortKey] || []; + cohorts[cohortKey].push(user); + }); + + return filterValidCohorts(cohorts); +} + +/** + * Calculates metrics for each cohort + * @param {Object} cohorts Grouped cohort data + * @param {Array} metrics Metrics to calculate + * @returns {Object} Cohort metrics + */ +function calculateCohortMetrics(cohorts, metrics) { + const cohortMetrics = {}; + + Object.entries(cohorts).forEach(([cohortKey, users]) => { + cohortMetrics[cohortKey] = { + size: users.length, + metrics: {}, + }; + + metrics.forEach((metric) => { + cohortMetrics[cohortKey].metrics[metric] = calculateMetric(users, metric); + }); + }); + + return cohortMetrics; +} + +/** + * Analyzes patterns across different cohorts + * @param {Object} cohortMetrics Calculated cohort metrics + * @returns {Object} Pattern analysis + */ +function analyzeCohortPatterns(cohortMetrics) { + const patterns = { + retentionCurves: calculateRetentionCurves(cohortMetrics), + engagementTrends: analyzeEngagementTrends(cohortMetrics), + cohortComparisons: compareCohorts(cohortMetrics), + }; + + return { + patterns, + significantPatterns: identifySignificantPatterns(patterns), + anomalies: detectCohortAnomalies(patterns), + }; +} + +/** + * Generates insights from cohort patterns + * @param {Object} patterns Analyzed cohort patterns + * @returns {Array} Actionable insights + */ +function generateCohortInsights(patterns) { + const insights = []; + + // Analyze retention insights + insights.push(...analyzeRetentionInsights(patterns.retentionCurves)); + + // Analyze engagement insights + insights.push(...analyzeEngagementInsights(patterns.engagementTrends)); + + // Analyze comparative insights + insights.push(...analyzeComparativeInsights(patterns.cohortComparisons)); + + return prioritizeInsights(insights); +} + +/** + * Calculates benchmarks across cohorts + * @param {Object} cohortMetrics Cohort metrics data + * @returns {Object} Benchmark calculations + */ +function calculateCohortBenchmarks(cohortMetrics) { + const benchmarks = { + retention: calculateRetentionBenchmarks(cohortMetrics), + engagement: calculateEngagementBenchmarks(cohortMetrics), + growth: calculateGrowthBenchmarks(cohortMetrics), + }; + + return { + benchmarks, + trends: analyzeBenchmarkTrends(benchmarks), + recommendations: generateBenchmarkRecommendations(benchmarks), + }; +} + +// Internal helper functions... + +function calculateTrend(values) { + // Simple linear regression + const n = values.length; + const indices = Array.from({ length: n }, (_, i) => i); + + const sumX = indices.reduce((a, b) => a + b, 0); + const sumY = values.reduce((a, b) => a + b, 0); + const sumXY = indices.reduce((sum, x, i) => sum + x * values[i], 0); + const sumXX = indices.reduce((sum, x) => sum + x * x, 0); + + const slope = (n * sumXY - sumX * sumY) / (n * sumXX - sumX * sumX); + return slope / (sumY / n); // Normalized slope +} + +function calculateMomentum(indicators) { + const shortTerm = indicators["7d"].trend; + const mediumTerm = indicators["30d"].trend; + const longTerm = indicators["90d"].trend; + + return { + value: shortTerm * 0.5 + mediumTerm * 0.3 + longTerm * 0.2, + direction: Math.sign(shortTerm), + strength: Math.abs(shortTerm) / Math.abs(longTerm), + }; +} + +function assessStability(indicators) { + return { + shortTerm: 1 / (1 + indicators["7d"].volatility), + mediumTerm: 1 / (1 + indicators["30d"].volatility), + longTerm: 1 / (1 + indicators["90d"].volatility), + }; +} + +const analytics = { + // User Engagement Functions + getUserEngagement: async (request, next) => { + try { + const { email, tenant } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No engagement data found for this user", + }) + ); + } + + const engagementMetrics = await calculateUserEngagementMetrics({ + totalActions: user.overallStats.totalActions, + services: user.monthlyStats[0].topServices, + endpoints: user.monthlyStats[0].uniqueEndpoints, + firstActivity: user.overallStats.firstActivity, + lastActivity: user.overallStats.lastActivity, + dailyStats: user.dailyStats, + }); + + return { + success: true, + message: "User engagement data retrieved successfully", + data: engagementMetrics, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + getEngagementMetrics: async (request, next) => { + try { + const { email, tenant, timeframe = "last30days" } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No metrics found for this user", + }) + ); + } + + const metrics = await generateActivityReport({ + userId: user._id, + timeframe, + metrics: ["all"], + includeProjections: true, + }); + + return { + success: true, + message: "Engagement metrics retrieved successfully", + data: metrics, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Activity Analysis Functions + getActivityReport: async (request, next) => { + try { + const { email, tenant, startDate, endDate } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No activity data found for this user", + }) + ); + } + + const report = await generateActivityReport({ + userId: user._id, + timeframe: { startDate, endDate }, + metrics: ["all"], + includeProjections: true, + }); + + return { + success: true, + message: "Activity report generated successfully", + data: report, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Cohort Analysis Functions + getCohortAnalysis: async (request, next) => { + try { + const { tenant, period = "monthly", metrics = ["all"] } = request.query; + + const cohortAnalysis = await performCohortAnalysis(tenant, { + cohortPeriod: period, + metrics, + }); + + return { + success: true, + message: "Cohort analysis completed successfully", + data: cohortAnalysis, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Predictive Analytics Functions + getPredictiveAnalytics: async (request, next) => { + try { + const { email, tenant } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No data found for predictions", + }) + ); + } + + const predictions = await predictEngagementTrends({ + dailyStats: user.dailyStats, + monthlyStats: user.monthlyStats, + currentMetrics: user.overallStats, + }); + + return { + success: true, + message: "Predictive analytics generated successfully", + data: predictions, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Service Adoption Functions + getServiceAdoption: async (request, next) => { + try { + const { email, tenant } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No service adoption data found", + }) + ); + } + + const adoptionAnalysis = analyzeServiceAdoption({ + services: user.monthlyStats[0].topServices, + endpoints: user.monthlyStats[0].uniqueEndpoints, + historicalUsage: user.dailyStats, + }); + + return { + success: true, + message: "Service adoption analysis completed successfully", + data: adoptionAnalysis, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Benchmark Functions + getBenchmarks: async (request, next) => { + try { + const { tenant, metrics = ["all"] } = request.query; + + const benchmarks = await calculateCohortBenchmarks({ + tenant, + metrics, + }); + + return { + success: true, + message: "Benchmarks retrieved successfully", + data: benchmarks, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Top Users Functions + getTopUsers: async (request, next) => { + try { + const { tenant, limit = 10, metric = "engagementScore" } = request.query; + + const topUsers = await ActivityModel(tenant) + .find({}) + .sort({ [`overallStats.${metric}`]: -1 }) + .limit(parseInt(limit)); + + return { + success: true, + message: "Top users retrieved successfully", + data: topUsers, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Aggregated Analytics Functions + getAggregatedAnalytics: async (request, next) => { + try { + const { + tenant, + metrics = ["all"], + timeframe = "last30days", + } = request.query; + + const aggregatedData = await ActivityModel(tenant).aggregate([ + { + $group: { + _id: null, + totalUsers: { $sum: 1 }, + averageEngagement: { $avg: "$overallStats.engagementScore" }, + totalActions: { $sum: "$overallStats.totalActions" }, + }, + }, + ]); + + return { + success: true, + message: "Aggregated analytics retrieved successfully", + data: aggregatedData[0], + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Retention Analysis Functions + getRetentionAnalysis: async (request, next) => { + try { + const { tenant, period = "monthly" } = request.query; + + const cohorts = await analyzeCohorts(); + const cohortMetrics = await analyzeCohortMetrics(); + const retentionAnalysis = await analyzeCohortTrends(); + + return { + success: true, + message: "Retention analysis completed successfully", + data: { + cohorts, + metrics: cohortMetrics, + trends: retentionAnalysis, + }, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Health Score Functions + getEngagementHealth: async (request, next) => { + try { + const { email, tenant } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No health score data found", + }) + ); + } + + const healthScore = await calculateUserEngagementScore({ + totalActions: user.monthlyStats[0].totalActions, + services: user.monthlyStats[0].topServices, + endpoints: user.monthlyStats[0].uniqueEndpoints, + firstActivity: user.monthlyStats[0].firstActivity, + lastActivity: user.monthlyStats[0].lastActivity, + }); + + return { + success: true, + message: "Health score retrieved successfully", + data: healthScore, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + + // Behavior Pattern Functions + getBehaviorPatterns: async (request, next) => { + try { + const { email, tenant } = request.query; + const user = await ActivityModel(tenant).findOne({ email }); + + if (!user) { + return next( + new HttpError("User not found", httpStatus.NOT_FOUND, { + message: "No behavior pattern data found", + }) + ); + } + + const patterns = await analyzeSeasonalPatterns(user.monthlyStats); + const trailingMetrics = calculateTrailingIndicators(user.dailyStats); + + return { + success: true, + message: "Behavior patterns analyzed successfully", + data: { + seasonalPatterns: patterns, + trailingIndicators: trailingMetrics, + }, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + enhancedGetUserStats: async (request, next) => { + try { + return { + success: false, + message: "enhancedGetUserStats temporarily disabled", + status: httpStatus.NOT_IMPLEMENTED, + errors: { message: "enhancedGetUserStats temporarily disabled" }, + }; + const { + tenant, + limit = 1000, + skip = 0, + startTime, + endTime, + } = request.query; + + const filter = { + ...generateFilter.logs(request, next), + timestamp: { + $gte: new Date(startTime), + $lte: new Date(endTime), + }, + "meta.service": { $nin: ["unknown", "none", "", null] }, + }; + + logger.info(`Applied filter: ${stringify(filter)}`); + + const pipeline = [ + { $match: filter }, + { + $facet: { + paginatedResults: [ + { + $group: { + _id: "$meta.email", + username: { $first: "$meta.username" }, + totalCount: { $sum: 1 }, + uniqueServices: { $addToSet: "$meta.service" }, + uniqueEndpoints: { $addToSet: "$meta.endpoint" }, + firstActivity: { $min: "$timestamp" }, + lastActivity: { $max: "$timestamp" }, + // Efficient service usage tracking + serviceUsage: { + $push: { + service: "$meta.service", + endpoint: "$meta.endpoint", + }, + }, + }, + }, + { $sort: { totalCount: -1 } }, + { $skip: skip }, + { $limit: limit }, + ], + totalCount: [ + { $group: { _id: "$meta.email" } }, + { $count: "total" }, + ], + }, + }, + ]; + + const [results] = await LogModel(tenant) + .aggregate(pipeline) + .allowDiskUse(true); + + const enrichedStats = results.paginatedResults.map((stat) => { + // Calculate service statistics + const serviceStats = stat.serviceUsage.reduce((acc, curr) => { + const { service } = curr; + acc[service] = (acc[service] || 0) + 1; + return acc; + }, {}); + + const topServices = Object.entries(serviceStats) + .map(([service, count]) => ({ + service: formatServiceName(service), + count, + })) + .sort((a, b) => b.count - a.count); + + const activityDuration = calculateActivityDuration( + stat.firstActivity, + stat.lastActivity, + new Date(startTime), + new Date(endTime) + ); + + return { + email: stat._id, + username: stat.username, + count: stat.totalCount, + uniqueServices: stat.uniqueServices, + uniqueEndpoints: stat.uniqueEndpoints, + firstActivity: stat.firstActivity, + lastActivity: stat.lastActivity, + topServices, + activityDuration, + engagementTier: calculateEngagementTier({ + count: stat.totalCount, + uniqueServices: stat.uniqueServices, + uniqueEndpoints: stat.uniqueEndpoints, + activityDuration, + }), + }; + }); + + return { + success: true, + message: "Successfully retrieved user statistics", + data: enrichedStats, + total: results.totalCount[0]?.total || 0, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { + message: error.message, + } + ) + ); + return; + } + }, + validateEnvironmentData: async ({ + tenant, + year = new Date().getFullYear(), + } = {}) => { + try { + return { + success: false, + message: " validateEnvironmentData temporarily disabled", + status: httpStatus.NOT_IMPLEMENTED, + errors: { message: " validateEnvironmentData temporarily disabled" }, + }; + logger.info( + `Running data validation for environment: ${constants.ENVIRONMENT}` + ); + + const startDate = new Date(`${year}-01-01`); + const endDate = new Date(`${year}-12-31`); + + const pipeline = [ + { + $match: { + timestamp: { + $gte: startDate, + $lte: endDate, + }, + }, + }, + { + $facet: { + monthlyDistribution: [ + { + $group: { + _id: { + year: { $year: "$timestamp" }, + month: { $month: "$timestamp" }, + }, + count: { $sum: 1 }, + uniqueUsers: { $addToSet: "$meta.email" }, + uniqueEndpoints: { $addToSet: "$meta.endpoint" }, + uniqueServices: { $addToSet: "$meta.service" }, + }, + }, + { $sort: { "_id.year": 1, "_id.month": 1 } }, + ], + userSample: [ + { + $group: { + _id: "$meta.email", + firstActivity: { $min: "$timestamp" }, + lastActivity: { $max: "$timestamp" }, + totalActions: { $sum: 1 }, + }, + }, + { $match: { totalActions: { $gt: 100 } } }, + { $limit: 10 }, + ], + systemMetrics: [ + { + $group: { + _id: null, + totalLogs: { $sum: 1 }, + avgLogsPerDay: { + $avg: { + $sum: 1, + }, + }, + uniqueServices: { $addToSet: "$meta.service" }, + uniqueEndpoints: { $addToSet: "$meta.endpoint" }, + }, + }, + ], + }, + }, + ]; + + const [results] = await LogModel(tenant) + .aggregate(pipeline) + .allowDiskUse(true); + + // Enrich the monthly distribution with percentage changes + const enrichedDistribution = results.monthlyDistribution.map( + (month, index, arr) => { + const prevMonth = index > 0 ? arr[index - 1] : null; + return { + year: month._id.year, + month: month._id.month, + totalLogs: month.count, + uniqueUsers: month.uniqueUsers.length, + uniqueEndpoints: month.uniqueEndpoints.length, + uniqueServices: month.uniqueServices.length, + percentageChange: prevMonth + ? ( + ((month.count - prevMonth.count) / prevMonth.count) * + 100 + ).toFixed(2) + : null, + }; + } + ); + + return { + success: true, + message: "Environment data validation completed", + data: { + monthlyDistribution: enrichedDistribution, + userSample: results.userSample, + systemMetrics: results.systemMetrics[0], + timeframe: { + startDate, + endDate, + totalDays: Math.floor( + (endDate - startDate) / (1000 * 60 * 60 * 24) + ), + }, + }, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`Error in validateEnvironmentData: ${stringify(error)}`); + throw new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { + message: error.message, + } + ); + } + }, + fetchUserStats: async ({ + emails = [], + year = new Date().getFullYear(), + tenant = "airqo", + chunkSize = 5, + timeWindowDays = 90, // Process data in 90-day windows + } = {}) => { + try { + return { + success: false, + message: "fetchUserStats temporarily disabled", + status: httpStatus.NOT_IMPLEMENTED, + errors: { message: "fetchUserStats temporarily disabled" }, + }; + const startDate = new Date(`${year}-01-01`); + const endDate = new Date(`${year}-12-31`); + const enrichedStats = []; + + // Process users in chunks to avoid memory overload + for (let i = 0; i < emails.length; i += chunkSize) { + const emailChunk = emails.slice(i, i + chunkSize); + + // Break the year into smaller time windows + const timeWindows = []; + let currentStart = new Date(startDate); + + while (currentStart < endDate) { + const windowEnd = new Date(currentStart); + windowEnd.setDate(windowEnd.getDate() + timeWindowDays); + const actualEnd = windowEnd > endDate ? endDate : windowEnd; + + timeWindows.push({ + start: new Date(currentStart), + end: new Date(actualEnd), + }); + + currentStart = new Date(actualEnd); + } + + // Process each time window for the current user chunk + const userStatsPromises = timeWindows.map(async (window) => { + const pipeline = [ + { + $match: { + timestamp: { + $gte: window.start, + $lte: window.end, + }, + "meta.email": { $in: emailChunk }, + "meta.service": { $nin: ["unknown", "none", "", null] }, + }, + }, + { + $group: { + _id: "$meta.email", + totalCount: { $sum: 1 }, + username: { $first: "$meta.username" }, + firstActivity: { $min: "$timestamp" }, + lastActivity: { $max: "$timestamp" }, + uniqueServices: { $addToSet: "$meta.service" }, + uniqueEndpoints: { $addToSet: "$meta.endpoint" }, + serviceUsage: { + $push: { + service: "$meta.service", + }, + }, + }, + }, + ]; + + return LogModel(tenant).aggregate(pipeline).allowDiskUse(true).exec(); + }); + + // Wait for all time windows to complete for current user chunk + const windowResults = await Promise.all(userStatsPromises); + + // Merge results for each user across time windows + const mergedUserStats = emailChunk.reduce((acc, email) => { + acc[email] = { + email, + totalCount: 0, + username: "", + firstActivity: null, + lastActivity: null, + uniqueServices: new Set(), + uniqueEndpoints: new Set(), + serviceUsage: new Map(), + }; + return acc; + }, {}); + + // Combine stats from all time windows + windowResults.flat().forEach((stat) => { + if (!stat || !stat._id) return; + + const userStats = mergedUserStats[stat._id]; + if (!userStats) return; + + userStats.totalCount += stat.totalCount; + userStats.username = stat.username; + + if ( + !userStats.firstActivity || + stat.firstActivity < userStats.firstActivity + ) { + userStats.firstActivity = stat.firstActivity; + } + if ( + !userStats.lastActivity || + stat.lastActivity > userStats.lastActivity + ) { + userStats.lastActivity = stat.lastActivity; + } + + stat.uniqueServices.forEach((s) => userStats.uniqueServices.add(s)); + stat.uniqueEndpoints.forEach((e) => userStats.uniqueEndpoints.add(e)); + + // Update service usage counts + stat.serviceUsage.forEach((usage) => { + const current = userStats.serviceUsage.get(usage.service) || 0; + userStats.serviceUsage.set(usage.service, current + 1); + }); + }); + + // Convert merged stats to final format + Object.values(mergedUserStats).forEach((stat) => { + if (stat.totalCount === 0) return; // Skip users with no activity + + const topServices = Array.from(stat.serviceUsage.entries()) + .map(([service, count]) => ({ + service: formatServiceName(service), + count, + })) + .sort((a, b) => b.count - a.count) + .slice(0, 5); + + const activityDays = Math.floor( + (stat.lastActivity - stat.firstActivity) / (1000 * 60 * 60 * 24) + ); + + const engagementScore = calculateEngagementScore({ + totalActions: stat.totalCount, + uniqueServices: stat.uniqueServices.size, + uniqueEndpoints: stat.uniqueEndpoints.size, + activityDays: activityDays || 1, + }); + + enrichedStats.push({ + email: stat.email, + username: stat.username, + totalActions: stat.totalCount, + firstActivity: stat.firstActivity, + lastActivity: stat.lastActivity, + uniqueServices: Array.from(stat.uniqueServices), + uniqueEndpoints: Array.from(stat.uniqueEndpoints), + topServices, + activityDuration: { + totalDays: activityDays, + totalMonths: Math.floor(activityDays / 30), + description: `Active for ${Math.floor(activityDays / 30)} months`, + }, + engagementTier: calculateEngagementTier(engagementScore), + }); + }); + + // Add a small delay between chunks to prevent overwhelming the server + if (i + chunkSize < emails.length) { + await new Promise((resolve) => setTimeout(resolve, 1000)); + } + } + + return enrichedStats; + } catch (error) { + logger.error(`Error in fetchUserStats: ${stringify(error)}`); + throw new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ); + } + }, + sendEmailsInBatches: async (userStats, batchSize = 100) => { + try { + return { + success: false, + message: "sendEmailsInBatches temporarily disabled", + status: httpStatus.NOT_IMPLEMENTED, + errors: { message: "sendEmailsInBatches temporarily disabled" }, + }; + let emailsSent = 0; + let lowEngagementCount = 0; + + for (let i = 0; i < userStats.length; i += batchSize) { + const batch = userStats.slice(i, i + batchSize); + const emailPromises = batch + .filter((userStat) => { + if (userStat.engagementTier === "Low Engagement") { + lowEngagementCount++; + return false; + } + return true; + }) + .map((userStat) => { + const { email, username } = userStat; + + return mailer + .yearEndEmail({ + email, + firstName: username.split(" ")[0], + lastName: username.split(" ")[1] || "", + userStat, + }) + .then((response) => { + if (response && response.success === false) { + logger.error( + `🐛🐛 Error sending year-end email to ${email}: ${stringify( + response + )}` + ); + } else { + emailsSent++; + } + return response; + }); + }); + + await Promise.all(emailPromises); + } + + return { + success: true, + message: + lowEngagementCount > 0 + ? `Sent year-end emails to ${emailsSent} users. Skipped ${lowEngagementCount} users with low engagement.` + : `Sent year-end emails to ${emailsSent} users`, + }; + } catch (error) { + logger.error(`🐛🐛 Error in sendEmailsInBatches: ${stringify(error)}`); + throw new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ); + } + }, + sendYearEndEmails: async (request) => { + try { + return { + success: false, + message: "sendYearEndEmails temporarily disabled", + status: httpStatus.NOT_IMPLEMENTED, + errors: { message: "sendYearEndEmails temporarily disabled" }, + }; + const { body, query } = request; + const { emails } = body; + const { tenant } = query; + + const userStats = await analytics.fetchUserStats({ emails, tenant }); + logObject("userStats", userStats); + + if (userStats.length > 0) { + const result = await analytics.sendEmailsInBatches(userStats); + return result; + } else { + logger.info("No user stats found for the provided emails."); + return { + success: false, + message: "No user stats found for the provided emails", + }; + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error -- ${stringify(error)}`); + throw new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ); + } + }, + listLogs: async (request, next) => { + try { + const { tenant, limit = 1000, skip = 0 } = request.query; + const filter = generateFilter.logs(request, next); + const responseFromListLogs = await LogModel(tenant).list( + { + filter, + limit, + skip, + }, + next + ); + if (responseFromListLogs.success === true) { + return { + success: true, + message: responseFromListLogs.message, + data: responseFromListLogs.data, + status: responseFromListLogs.status + ? responseFromListLogs.status + : httpStatus.OK, + }; + } else if (responseFromListLogs.success === false) { + const errorObject = responseFromListLogs.errors + ? responseFromListLogs.errors + : { message: "Internal Server Error" }; + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { + message: responseFromListLogs.message, + ...errorObject, + } + ) + ); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + listActivities: async (request, next) => { + try { + const { tenant, limit = 1000, skip = 0 } = request.query; + const filter = generateFilter.activities(request, next); + const responseFromListActivities = await ActivityModel(tenant).list( + { + filter, + limit, + skip, + }, + next + ); + if (responseFromListActivities.success === true) { + return { + success: true, + message: responseFromListActivities.message, + data: responseFromListActivities.data, + status: responseFromListActivities.status + ? responseFromListActivities.status + : httpStatus.OK, + }; + } else if (responseFromListActivities.success === false) { + const errorObject = responseFromListActivities.errors + ? responseFromListActivities.errors + : { message: "Internal Server Error" }; + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { + message: responseFromListActivities.message, + ...errorObject, + } + ) + ); + } + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, + getUserStats: async (request, next) => { + try { + const { tenant, limit = 1000, skip = 0 } = request.query; + const filter = generateFilter.logs(request, next); + + const pipeline = [ + { $match: filter }, + { + $group: { + _id: { email: "$meta.email", endpoint: "$meta.endpoint" }, + service: { $first: "$meta.service" }, + username: { $first: "$meta.username" }, + count: { $sum: 1 }, + }, + }, + { + $project: { + _id: 0, + email: "$_id.email", + endpoint: "$_id.endpoint", + count: 1, + service: "$service", + username: "$username", + }, + }, + ]; + + const getUserStatsResponse = await LogModel(tenant).aggregate(pipeline); + return { + success: true, + message: "Successfully retrieved the user statistics", + data: getUserStatsResponse, + status: httpStatus.OK, + }; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + return; + } + }, + listStatistics: async (tenant, next) => { + try { + const responseFromListStatistics = await UserModel(tenant).listStatistics( + tenant + ); + return responseFromListStatistics; + } catch (error) { + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, +}; + +module.exports = analytics; diff --git a/src/auth-service/utils/create-candidate.js b/src/auth-service/utils/create-candidate.js index 81ec922cc7..2f3a00c0ea 100644 --- a/src/auth-service/utils/create-candidate.js +++ b/src/auth-service/utils/create-candidate.js @@ -19,6 +19,15 @@ const { HttpError } = require("@utils/errors"); const createCandidate = { create: async (request, next) => { try { + return { + success: false, + message: "Deprecated Functionality", + status: httpStatus.GONE, + errors: { + message: + "Please contact support@airqo.net, Candidates are deprecated", + }, + }; const { firstName, lastName, email, tenant, network_id } = { ...request.body, ...request.query, @@ -127,6 +136,15 @@ const createCandidate = { }, list: async (request, next) => { try { + return { + success: false, + message: "Deprecated Functionality", + status: httpStatus.GONE, + errors: { + message: + "Please contact support@airqo.net, Candidates are deprecated", + }, + }; const { tenant, limit, skip } = { ...request.body, ...request.query, @@ -157,6 +175,15 @@ const createCandidate = { }, update: async (request, next) => { try { + return { + success: false, + message: "Deprecated Functionality", + status: httpStatus.GONE, + errors: { + message: + "Please contact support@airqo.net, Candidates are deprecated", + }, + }; const { query, body } = request; const filter = generateFilter.candidates(request, next); const update = body; @@ -184,6 +211,15 @@ const createCandidate = { }, confirm: async (request, next) => { try { + return { + success: false, + message: "Deprecated Functionality", + status: httpStatus.GONE, + errors: { + message: + "Please contact support@airqo.net, Candidates are deprecated", + }, + }; const { tenant, firstName, lastName, email, network_id } = { ...request.body, ...request.query, @@ -330,6 +366,15 @@ const createCandidate = { }, delete: async (request, next) => { try { + return { + success: false, + message: "Deprecated Functionality", + status: httpStatus.GONE, + errors: { + message: + "Please contact support@airqo.net, Candidates are deprecated", + }, + }; const { tenant } = { ...request.query }; const filter = generateFilter.candidates(request, next); const responseFromRemoveCandidate = await CandidateModel( diff --git a/src/auth-service/utils/create-group.js b/src/auth-service/utils/create-group.js index 3c501cb431..08dbe1831e 100644 --- a/src/auth-service/utils/create-group.js +++ b/src/auth-service/utils/create-group.js @@ -1095,6 +1095,86 @@ const createGroup = { ); } }, + setManager: async (request, next) => { + try { + const { grp_id, user_id } = request.params; + const { tenant } = request.query; + const user = await UserModel(tenant).findById(user_id).lean(); + const group = await GroupModel(tenant).findById(grp_id).lean(); + + if (isEmpty(user)) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "User not found", + }) + ); + } + + if (isEmpty(group)) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: "Group not found", + }) + ); + } + + if ( + group.grp_manager && + group.grp_manager.toString() === user_id.toString() + ) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: `User ${user_id.toString()} is already the group manager`, + }) + ); + } + + logObject("the user object", user); + // Updated check to use group_roles array + const userGroupIds = user.group_roles.map((groupRole) => + groupRole.group.toString() + ); + + if (!userGroupIds.includes(grp_id.toString())) { + return next( + new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { + message: `Group ${grp_id.toString()} is not part of User's groups, not authorized to manage this group`, + }) + ); + } + + const updatedGroup = await GroupModel(tenant).findByIdAndUpdate( + grp_id, + { grp_manager: user_id }, + { new: true } + ); + + if (!isEmpty(updatedGroup)) { + return { + success: true, + message: "User assigned to Group successfully", + status: httpStatus.OK, + data: updatedGroup, + }; + } else { + return next( + new HttpError("Bad Request", httpStatus.BAD_REQUEST, { + message: "No group record was updated", + }) + ); + } + } catch (error) { + logObject("the error", error); + logger.error(`🐛🐛 Internal Server Error ${error.message}`); + return next( + new HttpError( + "Internal Server Error", + httpStatus.INTERNAL_SERVER_ERROR, + { message: error.message } + ) + ); + } + }, }; module.exports = createGroup; diff --git a/src/auth-service/utils/create-network.js b/src/auth-service/utils/create-network.js index eb2174bcc4..1e817e55f3 100644 --- a/src/auth-service/utils/create-network.js +++ b/src/auth-service/utils/create-network.js @@ -731,7 +731,7 @@ const createNetwork = { const network = await NetworkModel(tenant).findById(net_id).lean(); if (isEmpty(user)) { - next( + return next( new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { message: "User not found", }) @@ -739,7 +739,7 @@ const createNetwork = { } if (isEmpty(network)) { - next( + return next( new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { message: "Network not found", }) @@ -750,17 +750,20 @@ const createNetwork = { network.net_manager && network.net_manager.toString() === user_id.toString() ) { - next( + return next( new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { message: `User ${user_id.toString()} is already the network manager`, }) ); } - if ( - !user.networks.map((id) => id.toString()).includes(net_id.toString()) - ) { - next( + // Updated check to use network_roles array + const userNetworkIds = user.network_roles.map((networkRole) => + networkRole.network.toString() + ); + + if (!userNetworkIds.includes(net_id.toString())) { + return next( new HttpError("Bad Request Error", httpStatus.BAD_REQUEST, { message: `Network ${net_id.toString()} is not part of User's networks, not authorized to manage this network`, }) @@ -781,7 +784,7 @@ const createNetwork = { data: updatedNetwork, }; } else { - next( + return next( new HttpError("Bad Request", httpStatus.BAD_REQUEST, { message: "No network record was updated", }) @@ -789,7 +792,7 @@ const createNetwork = { } } catch (error) { logger.error(`🐛🐛 Internal Server Error ${error.message}`); - next( + return next( new HttpError( "Internal Server Error", httpStatus.INTERNAL_SERVER_ERROR, diff --git a/src/auth-service/utils/email.msgs.js b/src/auth-service/utils/email.msgs.js index 69a46ef95c..ae3c9939e6 100644 --- a/src/auth-service/utils/email.msgs.js +++ b/src/auth-service/utils/email.msgs.js @@ -173,6 +173,85 @@ module.exports = { `; return constants.EMAIL_BODY({ email, content, name }); }, + yearEndSummary: ({ + username = "", + email = "", + engagementTier = "", + activityDuration = {}, + topServiceDescription = "", + topServices = [], + mostUsedEndpoints = [], + } = {}) => { + const content = `
Congratulations on an amazing year with AirQo Analytics! 🎉
+ + ${ + engagementTier || + activityDuration.description || + topServiceDescription + ? ` +Engagement Level: ${engagementTier}
` + : "" + } + ${ + activityDuration.description + ? `Activity Duration: ${activityDuration.description}
` + : "" + } + + ${ + topServiceDescription + ? `Top Service: ${topServiceDescription}
` + : "" + } ++ ${index + 1}. ${service.service} (Used ${ + service.count + } times) +
+ ` + ) + .join("")} +Thank you for being an incredible part of our community!
+ +Best wishes,
The AirQo Analytics Team
+ Please visit our website to learn more about us. AirQo +
+