diff --git a/api/api.js b/api/api.js index 92d2cbb..eac7e0d 100644 --- a/api/api.js +++ b/api/api.js @@ -2,8 +2,10 @@ import { Router } from "express"; import db from "./db.js"; import { decideStatus } from "./functions/decideStatus.js"; +import { getBoxPlotData } from "./functions/getBoxPlotData.js"; import { lookupEmail } from "./functions/lookupEmail.js"; import { processImportFiles } from "./functions/processImportFiles.js"; +import { scoreNormaliser } from "./functions/scoreNormaliser.js"; import { updateDbUsers } from "./functions/updateDbUsers.js"; import { updateUsersActivity } from "./functions/updateUsersActivity.js"; import messageRouter from "./messages/messageRouter.js"; @@ -116,24 +118,18 @@ api.get("/users/status-counts", async (req, res) => { const rawConfigTable = await db.query("SELECT * FROM config_table"); const configTable = rawConfigTable.rows[0]; - const overalStatus = { low: 0, medium: 0, high: 0, inactive: 0 }; - - for (const user of allusers) { - const status = await decideStatus( - configTable, - user.user_id, - userActivities, - ); + const normalisedScores = scoreNormaliser( + allusers, + userActivities, + configTable, + ); - if (status.success) { - overalStatus[status.status] += 1; - } else { - logger.warn(status.message); - return res.status(404).json({}); - } - } + const boxPlotData = getBoxPlotData(normalisedScores); + const totalStatus = await decideStatus(normalisedScores, configTable); - return res.status(200).json({ status: overalStatus }); + return res + .status(200) + .json({ status: totalStatus, boxPlotData: boxPlotData }); } catch (error) { res.status(500).json({ msg: "server error" }); } @@ -157,6 +153,28 @@ api.put("/config", async (req, res) => { !Number.isFinite(reactionsWeighting) || !Number.isFinite(reactionsReceivedWeighting) ) { + logger.error("input values are inavalid"); + return res.status(400).json({ message: "total weight must be 100" }); + } + + const totalWeights = + messagesWeighting + reactionsWeighting + reactionsReceivedWeighting; + + if (totalWeights !== 100) { + res.status(400).json({}); + } + + if (lowTreshholds + mediumTreshholds + highTreshHolds !== 100) { + logger.error("Thresholds must add up to 100."); + return res.status(400).json({}); + } + + if ( + !(lowTreshholds < mediumTreshholds && mediumTreshholds < highTreshHolds) + ) { + logger.error( + "Thresholds must be in increasing order: low < medium < high.", + ); return res.status(400).json({}); } diff --git a/api/functions/decideScore.js b/api/functions/decideScore.js index 1e75ef8..5f89f9b 100644 --- a/api/functions/decideScore.js +++ b/api/functions/decideScore.js @@ -23,18 +23,20 @@ export const decideScore = ({ messages, reactions, reactionsReceived, - messageWeight = 3, - reactionWeight = 1, - reactionsReceivedWeight = 1, + configTable, }) => { if (!Number.isFinite(messages)) messages = 0; if (!Number.isFinite(reactions)) reactions = 0; if (!Number.isFinite(reactionsReceived)) reactionsReceived = 0; + const messageWeight = configTable.message_weighting; + const reactionWeight = configTable.reactions_weighting; + const reactionsReceivedWeight = configTable.reactions_received_weighting; + if (messages <= 0 && reactions <= 0) return 0; // if no messages then no reactions received. - if (!messages) return reactionWeight * reactions; + if (!messages) return (reactionWeight * reactions) / 100; if (!reactions) return ( diff --git a/api/functions/decideScore.test.js b/api/functions/decideScore.test.js deleted file mode 100644 index 4e8dacb..0000000 --- a/api/functions/decideScore.test.js +++ /dev/null @@ -1,130 +0,0 @@ -import { decideScore } from "./decideScore.js"; - -describe("decideScore", () => { - it("should return 0 when non-numeric arguments are passed to the function", () => { - const score = decideScore({ messages: "3", reactions: "4" }); - expect(score).toBe(0); - }); - - it("should return 13 when there are 3 messages and 4 reactions", () => { - const score = decideScore({ messages: 3, reactions: 4 }); - expect(score).toBe(13); - }); - - it("should return 69 when there are 22 messages and 3 reactions", () => { - const score = decideScore({ messages: 22, reactions: 3 }); - expect(score).toBe(69); - }); - - it("should return 45 when there are 10 messages and 10 reactions and reactionsReceived = 5", () => { - const score = decideScore({ - messages: 10, - reactions: 10, - reactionsReceived: 5, - }); - expect(score).toBe(45); - }); - - it("should handle when there are no messages and reactions", () => { - const score = decideScore({ messages: 0, reactions: 0 }); - expect(score).toBe(0); - }); - - it("should return score based on reactionsReceived when no messages and no reactions", () => { - const score = decideScore({ - messages: 0, - reactions: 0, - reactionsReceived: Math.floor(Math.random() * 10), - }); - expect(score).toBe(0); - }); - - it("should handle when messages and reactions are negative", () => { - const score = decideScore({ messages: -1, reactions: -1 }); - expect(score).toBe(0); - }); - - it("should handle when messages and reactions are null or undefined", () => { - const score = decideScore({ messages: null, reactions: undefined }); - expect(score).toBe(0); - }); - - it("should handle when only messages are provided", () => { - const score = decideScore({ messages: 3 }); - expect(score).toBe(9); - }); - - it("should handle if reactions are not provided", () => { - const score = decideScore({ messages: 3, reactionsReceived: 5 }); - expect(score).toBe(14); - }); - - it("should handle when only reactions are provided", () => { - const score = decideScore({ reactions: 3 }); - expect(score).toBe(3); - }); - - it("should handle when reactionsReceived are provided but no messages", () => { - const score = decideScore({ reactionsReceived: 4 }); - expect(score).toBe(0); - }); - - it("should return the correct weighted score when weighting parameters are customized", () => { - const score = decideScore({ - messages: 5, - reactions: 2, - reactionsReceived: 3, - messageWeight: 4, - reactionWeight: 2, - reactionsReceivedWeight: 1, - }); - expect(score).toBe(27); // (4*5 + 2*2 + 1*3) - }); - - it("should return the correct weighted score when only reactionWeight is customized", () => { - const score = decideScore({ - messages: 5, - reactions: 2, - reactionsReceived: 3, - messageWeight: 3, // Only messageWeight customized - }); - expect(score).toBe(20); // (3*5 + 1*2 + 1*3) - }); - - it("should return the correct weighted score when only reactionsReceivedWeight is customized", () => { - const score = decideScore({ - messages: 5, - reactions: 2, - reactionsReceived: 3, - reactionWeight: 2, // Only reactionsWeight customized - }); - expect(score).toBe(22); // (3*5 + 2*2 + 1*3) - }); - - it("should return the correct weighted score when only messages and reactions are provided", () => { - const score = decideScore({ - messages: 5, - reactions: 2, - // Default weights will be used: messageWeight=3, reactionWeight=1, reactionsReceivedWeight=1 - }); - expect(score).toBe(17); // (3*5 + 1*2 + 1*0) - }); - - it("should return the correct weighted score when only messages and reactionsReceived are provided", () => { - const score = decideScore({ - messages: 5, - reactionsReceived: 3, - // Default weights will be used: messageWeight=3, reactionWeight=1, reactionsReceivedWeight=1 - }); - expect(score).toBe(18); // (3*5 + 1*0 + 1*3) - }); - - it("should return the correct weighted score when only reactions and reactionsReceived are provided", () => { - const score = decideScore({ - reactions: 2, - reactionsReceived: 3, - // Default weights will be used: messageWeight=3, reactionWeight=1, reactionsReceivedWeight=1 - }); - expect(score).toBe(2); // ( 1*2) When messages is not provided reactionsReceived is ignored - }); -}); diff --git a/api/functions/decideStatus.js b/api/functions/decideStatus.js index 4f13e94..6674bc8 100644 --- a/api/functions/decideStatus.js +++ b/api/functions/decideStatus.js @@ -1,63 +1,41 @@ import logger from "../utils/logger.js"; -import { aggregateUserActivity } from "./aggregateUserActivity.js"; -import { decideScore } from "./decideScore.js"; - /** - * Determines the status of a user based on their activity and the provided configuration table. - * - * This function aggregates the user's activity, calculates a score based on messages and reactions, - * and then returns a status based on predefined threshold values. If any part of the process fails, - * an error message will be returned. This is an asynchronous function. - * - * @param {Array} configTable - An array of configuration objects, each containing thresholds and weights for score calculation. retrieved from DB. - * @param {number} configTable[].low_threshold - The threshold for the "inactive" status. - * @param {number} configTable[].medium_threshold - The threshold for the "low" status. - * @param {number} configTable[].high_threshold - The threshold for the "medium" status. - * @param {number} configTable[].message_weighting - The weight given to messages in the score calculation. - * @param {number} configTable[].reactions_weighting - The weight given to reactions in the score calculation. - * - * @param {string} userId - The ID of the user whose status is to be determined. - * @param {Array} userActivity - The array of user activity objects returned from another function. - * @param {string} userActivity[].user_id - The user ID associated with the activity. - * @param {number} userActivity[].messages - The number of messages sent by the user. - * @param {number} userActivity[].reactions - The number of reactions sent by the user. - * @param {number} userActivity[].reactions_received - The number of reactions received by the user. - * - * @returns {Object} An object containing the success status and the user's activity status. - * @returns {boolean} return.success - Whether the operation was successful. - * @returns {string} return.status - The status of the user: "inactive", "low", "medium", or "high", or an error message if the process failed. - * @throws {Error} If the user activity could not be aggregated or if there is an issue with the score calculation. - + * Determines the status of users based on their normalised scores. + * + * This function takes an array of normalised scores and compares each score with + * the provided thresholds in the `configTable`. It categorises users into + * different status groups: inactive, low, medium, and high activity based on + * the score ranges defined in the `configTable`. + * + * @param {number[]} normalisedScores - An array of normalised scores representing user activity levels. + * @param {Object} configTable - An object containing threshold values for determining user status. + * @param {number} configTable.low_threshold - The threshold below which users are considered inactive. + * @param {number} configTable.medium_threshold - The threshold for users to be categorised as low activity. + * @param {number} configTable.high_threshold - The threshold above which users are considered to have high activity. + * + * @returns {Object} An object representing the count of users in each activity status category: + * `inactive`, `low`, `medium`, and `high`. + * Example: `{ inactive: 5, low: 10, medium: 15, high: 20 }`. + * + * @throws {Error} Throws an error if there is an issue during the status determination process. */ -export const decideStatus = async (configTable, userId, userActivity) => { +export const decideStatus = async (normalisedScores, configTable) => { try { - const aggregatedActivity = aggregateUserActivity(userId, userActivity); - - if (!aggregatedActivity.success) { - logger.error("activity has not been fetched correctly"); - throw Error("activity has not been fetched correctly"); + const finalStatus = { low: 0, medium: 0, high: 0, inactive: 0 }; + for (const score of normalisedScores) { + if (score < configTable.low_threshold) { + finalStatus.inactive += 1; + } else if (score < configTable.medium_threshold) { + finalStatus.low += 1; + } else if (score < configTable.high_threshold) { + finalStatus.medium += 1; + } else { + finalStatus.high += 1; + } } - const score = decideScore({ - messages: aggregatedActivity.countActivity.messagesCount, - reactions: aggregatedActivity.countActivity.reactionsCount, - reactionsReceived: - aggregatedActivity.countActivity.reactionsReceivedCount, - messageWeight: configTable.message_weighting, - reactionWeight: configTable.reactions_weighting, - reactionsReceivedWeight: configTable.reactions_received_weighting, - }); - - if (score < configTable.low_threshold) { - return { success: true, status: "inactive" }; - } else if (score < configTable.medium_threshold) { - return { success: true, status: "low" }; - } else if (score < configTable.high_threshold) { - return { success: true, status: "medium" }; - } else { - return { success: true, status: "high" }; - } + return finalStatus; } catch (error) { logger.error(error); throw error; diff --git a/api/functions/getBoxPlotData.js b/api/functions/getBoxPlotData.js new file mode 100644 index 0000000..669d8c1 --- /dev/null +++ b/api/functions/getBoxPlotData.js @@ -0,0 +1,63 @@ +/** + * Calculates box plot statistics for an array of scores. + * + * This function computes the minimum, first quartile (Q1), median, third quartile (Q3), + * maximum, and outliers based on the given scores. It returns these values to give + * insight into the distribution of the data. + * + * @param {number[]} scores - An array of numerical scores to analyse. + * @returns {Object|null} An object containing min, Q1, median, Q3, max, and outliers, or null if the array is empty. + */ +export const getBoxPlotData = (scores) => { + if (!scores.length) return null; + + const sorted = [...scores].sort((a, b) => a - b); + const n = sorted.length; + + // Function to calculate the percentile value for a given percentile (p). + const percentile = (p) => { + // Calculate the index of the desired percentile. + const index = (p / 100) * (n - 1); + + // Find the lower and upper bounds of the index. + const lower = Math.floor(index); + const upper = Math.ceil(index); + + // If the lower and upper indices are the same, return the value at that index. + if (lower === upper) return sorted[lower]; + + // Otherwise, interpolate between the values at the lower and upper indices. + return sorted[lower] + (sorted[upper] - sorted[lower]) * (index - lower); + }; + + // Calculate the first quartile (Q1), median, and third quartile (Q3) percentiles. + const q1 = percentile(25); + const median = percentile(50); + const q3 = percentile(75); + + // Calculate the Interquartile Range (IQR) as the difference between Q3 and Q1. + const iqr = q3 - q1; + + // Calculate the minimum score, ensuring it is not lower than Q1 - 1.5 * IQR. + // This ensures that we do not count extreme outliers as part of the "min". + const min = Math.max(Math.min(...sorted), q1 - 1.5 * iqr); + + // Calculate the maximum score, ensuring it is not higher than Q3 + 1.5 * IQR. + // This ensures that we do not count extreme outliers as part of the "max". + const max = Math.min(Math.max(...sorted), q3 + 1.5 * iqr); + + // Identify any scores that are considered outliers, based on being lower than + // Q1 - 1.5 * IQR or higher than Q3 + 1.5 * IQR. + const outliers = sorted.filter( + (score) => score < q1 - 1.5 * iqr || score > q3 + 1.5 * iqr, + ); + + return { + min, + q1, + median, + q3, + max, + outliers, + }; +}; diff --git a/api/functions/scoreNormaliser.js b/api/functions/scoreNormaliser.js new file mode 100644 index 0000000..f31e080 --- /dev/null +++ b/api/functions/scoreNormaliser.js @@ -0,0 +1,44 @@ +import { aggregateUserActivity } from "./aggregateUserActivity.js"; +import { decideScore } from "./decideScore.js"; + +/** + * Calculates the total score for a specific user based on their activity and configuration weights. + * + * @param {string|number} userId - The ID of the user. + * @param {Object} userActivity - An object containing activity data for all users. + * @param {Object} configTable - Configuration table with weights for messages, reactions, and reactions received. + * @returns {number} - The total activity score for the user. + */ +const decideTotalScore = (userId, userActivity, configTable) => { + const aggregatedActivity = aggregateUserActivity(userId, userActivity); + const totalScore = decideScore({ + messages: aggregatedActivity.countActivity.messagesCount, + reactions: aggregatedActivity.countActivity.reactionsCount, + reactionsReceived: aggregatedActivity.countActivity.reactionsReceivedCount, + configTable, + }); + return totalScore; +}; + +/** + * Normalises the activity scores of a list of users to a 0–100 scale, + * where the highest score becomes 100 and others are scaled relative to it. + * + * @param {Array} usersArray - Array of user objects, each containing at least a `user_id` property. + * @param {Object} userActivity - An object containing raw activity data for users. + * @param {Object} configTable - Configuration object that includes the weighting of each activity type. + * @returns {Array} - An array of normalised scores between 0 and 100. + */ +export const scoreNormaliser = (usersArray, userActivity, configTable) => { + const scoreArray = []; + for (const user of usersArray) { + scoreArray.push(decideTotalScore(user.user_id, userActivity, configTable)); + } + + const maxScore = Math.max(...scoreArray); + const normalisedScores = scoreArray.map((score) => + Math.round((score / maxScore) * 100), + ); + + return normalisedScores; +};