diff --git a/.github/workflows/push.yml b/.github/workflows/push.yml index 59f0367..46dde0f 100644 --- a/.github/workflows/push.yml +++ b/.github/workflows/push.yml @@ -2,9 +2,9 @@ name: CI on: push: - branches: [main] + branches: [main, staging] pull_request: - branches: [main] + branches: [main, staging] jobs: windows: diff --git a/api/api.js b/api/api.js index ac9e504..c4ce56a 100644 --- a/api/api.js +++ b/api/api.js @@ -1,8 +1,11 @@ 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"; @@ -14,6 +17,25 @@ const api = Router(); api.use("/message", messageRouter); +/** + * @swagger + * /subscribe: + * post: + * summary: Subscribe a user by email + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * email: + * type: string + * example: example@email.com + * responses: + * 302: + * description: Redirects on success or failure + */ api.post("/subscribe", async (req, res) => { const email = req.body.email; try { @@ -60,6 +82,51 @@ api.post("/subscribe", async (req, res) => { } }); +/** + * @swagger + * /fetch-users: + * get: + * summary: Retrieve all users from the database + * responses: + * 200: + * description: A list of users + * content: + * application/json: + * schema: + * type: array + * items: + * type: object + * properties: + * id: + * type: integer + * name: + * type: string + * email: + * type: string + * 404: + * description: No users found + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: false + * message: + * type: string + * example: User not found + * 500: + * description: Internal server error + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Internal Server Error + */ api.get("/fetch-users", async (req, res) => { try { const result = await db.query("SELECT * FROM all_users"); @@ -74,6 +141,55 @@ api.get("/fetch-users", async (req, res) => { } }); +/** + * @swagger + * /upload: + * post: + * summary: Upload and process a Slack export file + * description: Accepts a Slack export ZIP file, extracts its contents, processes the data, and updates the database with user and activity information. + * requestBody: + * required: true + * content: + * multipart/form-data: + * schema: + * type: object + * properties: + * file: + * type: string + * format: binary + * description: The Slack export ZIP file to upload and process. + * responses: + * 200: + * description: The file was successfully processed, and the database was updated. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * 400: + * description: Bad request. The uploaded file is invalid or missing. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Invalid file upload. + * 500: + * description: Internal server error. An error occurred while processing the file or updating the database. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Internal Server Error. + */ api.post("/upload", processUpload, async (req, res) => { try { const slackZipBuffer = req.file.buffer; @@ -81,25 +197,200 @@ api.post("/upload", processUpload, async (req, res) => { const processedActivity = processImportFiles(extractedDir); - const isUsersInserted = await updateDbUsers(extractedDir, db); - - if (!isUsersInserted.success) { - return res.status(500).json({}); - } + await updateDbUsers(extractedDir, db); const isActivityInserted = await updateUsersActivity(processedActivity, db); - if (!isActivityInserted.success) { - return res.status(500).json({}); + if (isActivityInserted) { + logger.info( + "isActivityInserted in the upload endpoint? => inserted successfully", + ); + return res.status(200).json({}); } - return res.status(200).json({}); + return res.status(500).json({}); } catch (error) { logger.error(error); return res.status(500).json({}); } }); +/** + * @swagger + * /users/status-counts: + * get: + * summary: Get user activity status counts + * description: Retrieves user activity data within a specified date range, normalizes the scores, and calculates the status and box plot data. + * parameters: + * - in: query + * name: start_date + * required: true + * schema: + * type: string + * format: date + * description: The start date for the activity data (YYYY-MM-DD). + * - in: query + * name: end_date + * required: true + * schema: + * type: string + * format: date + * description: The end date for the activity data (YYYY-MM-DD). + * responses: + * 200: + * description: Successfully retrieved user activity status counts and box plot data. + * content: + * application/json: + * schema: + * type: object + * properties: + * status: + * type: array + * items: + * type: object + * properties: + * user_id: + * type: string + * description: The ID of the user. + * status: + * type: string + * description: The calculated status of the user. + * boxPlotData: + * type: object + * description: Data for generating a box plot of normalized scores. + * 400: + * description: Bad request. Missing or invalid query parameters. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Missing or invalid query parameters. + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * type: object + * properties: + * msg: + * type: string + * example: server error. + */ +api.get("/users/status-counts", async (req, res) => { + const startDate = req.query.start_date; + const endDate = req.query.end_date; + try { + const dbFetchedActivity = await db.query( + "select user_id , messages , reactions , reactions_received FROM slack_user_activity WHERE date BETWEEN $1 AND $2 ", + [startDate, endDate], + ); + const userActivities = dbFetchedActivity.rows; + + const rawUsers = await db.query("SELECT user_id FROM all_users"); + const allusers = rawUsers.rows; + + const rawConfigTable = await db.query("SELECT * FROM config_table"); + const configTable = rawConfigTable.rows[0]; + + const normalisedScores = scoreNormaliser( + allusers, + userActivities, + configTable, + ); + + const boxPlotData = getBoxPlotData(normalisedScores); + const totalStatus = await decideStatus(normalisedScores, configTable); + + return res + .status(200) + .json({ status: totalStatus, boxPlotData: boxPlotData }); + } catch (error) { + res.status(500).json({ msg: "server error" }); + } +}); + +/** + * @swagger + * /config: + * put: + * summary: Update configuration settings + * description: Updates the thresholds and weightings for the application configuration. + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * properties: + * lowTreshholds: + * type: integer + * description: The low threshold value. + * example: 10 + * mediumTreshholds: + * type: integer + * description: The medium threshold value. + * example: 20 + * highTreshHolds: + * type: integer + * description: The high threshold value. + * example: 30 + * messagesWeighting: + * type: number + * description: The weighting for messages. + * example: 1.5 + * reactionsWeighting: + * type: number + * description: The weighting for reactions. + * example: 2.0 + * reactionsReceivedWeighting: + * type: number + * description: The weighting for reactions received. + * example: 2.5 + * responses: + * 200: + * description: Configuration updated successfully. + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * example: true + * 400: + * description: Bad request. Invalid or missing input data. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Invalid input data. + * 404: + * description: Configuration not found. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Configuration not found. + * 500: + * description: Internal server error. + * content: + * application/json: + * schema: + * type: object + * properties: + * message: + * type: string + * example: Internal Server Error. + */ api.put("/config", async (req, res) => { const { lowTreshholds, @@ -118,6 +409,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/app.js b/api/app.js index 9349b17..ec84f2c 100644 --- a/api/app.js +++ b/api/app.js @@ -2,6 +2,7 @@ import express from "express"; import apiRouter from "./api.js"; import db from "./db.js"; +import setupSwagger from "./swagger.js"; import config from "./utils/config.cjs"; import { asyncHandler, @@ -33,6 +34,8 @@ app.get( }), ); +setupSwagger(app); + app.use(apiRoot, apiRouter); app.use(clientRouter(apiRoot)); diff --git a/api/functions/aggregateUserActivity.js b/api/functions/aggregateUserActivity.js index 3e782b0..4766ac2 100644 --- a/api/functions/aggregateUserActivity.js +++ b/api/functions/aggregateUserActivity.js @@ -18,6 +18,7 @@ import logger from "../utils/logger.js"; *- `reactionsCount` (number): The total number of reactions by the user. *- `reactionsReceivedCount` (number): The total number of reactions received by the user. * + * * @returns {object} - If an error occurs during the aggregation process, an error is logged and * object containing a boolean with false and a message is returned. */ 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 765cd41..6674bc8 100644 --- a/api/functions/decideStatus.js +++ b/api/functions/decideStatus.js @@ -1,64 +1,43 @@ 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. - + * 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); - - // @todo updated version of decideScore should be replaced when it was merged - if (!aggregatedActivity.success) { - return { - success: false, - status: "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, - messageWeight: configTable.message_weighting, - reactionWeight: configTable.reactions_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); - return { success: false, status: "unknown error happened" }; + 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; +}; diff --git a/api/functions/updateDbUsers.js b/api/functions/updateDbUsers.js index 60b4033..b448e1d 100644 --- a/api/functions/updateDbUsers.js +++ b/api/functions/updateDbUsers.js @@ -1,18 +1,32 @@ import logger from "../utils/logger.js"; /** - * Updates the users in the database by processing the users.json file from the extracted directory. - * Filters out invalid users, and performs a batch upsert into the 'all_users' table. + * Updates the database with active users from a provided directory of JSON data. * - * @param {Array} extractedDir - An array of directory entries (files and folders) extracted from a zip. - * @param {Object} db - The database client used to interact with the database. - * @returns {Object} - Returns an object indicating success or failure with a message. + * The function performs the following: + * - Verifies the validity of the directory and contents of `users.json`. + * - Filters out active users based on specific criteria. + * - Performs a batch insert or update of user records into the `all_users` table. + * - If any step fails, the function will throw an error and log relevant messages. + * + * @param {Array} extractedDir - Array of directory entries, each containing a `name` and `content` property. + * @param {Object} db - Database client to execute queries (typically a `pg` client). + * + * @returns {Promise} Resolves with `true` if users were successfully inserted into the database. + * + * @throws {Error} If any of the following occur: + * - Invalid or missing `extractedDir`. + * - Missing or invalid `users.json` file. + * - Invalid or missing `users.json` content. + * - Error parsing the `users.json` file. + * - No active users found. + * - Any database operation failure, which will trigger a rollback. */ export const updateDbUsers = async (extractedDir, db) => { try { // if directory passed is invalid if (!extractedDir) { - return { success: false, message: "invalid passed directory" }; + throw Error("invalid passed directory"); } // Find users.json file in the directory @@ -22,19 +36,14 @@ export const updateDbUsers = async (extractedDir, db) => { if (!usersFileEntry) { logger.error("users json file not found in the directory"); - return { - success: false, - message: "users json file not found in the directory", - }; + + throw Error("users json file not found in the directory"); } const usersFileContent = usersFileEntry.content; if (!usersFileContent) { - return { - success: false, - message: "users.json content is missing or invalid", - }; + throw Error("users.json content is missing or invalid"); } let usersList; @@ -42,7 +51,7 @@ export const updateDbUsers = async (extractedDir, db) => { usersList = JSON.parse(usersFileContent); } catch (error) { logger.error("Error parsing users.json"); - return { success: false, message: "Error in parsing users.json" }; + throw Error("Error in parsing users.json"); } const activeUsers = usersList.filter( @@ -57,10 +66,7 @@ export const updateDbUsers = async (extractedDir, db) => { if (activeUsers.length === 0) { logger.error("active user is empty.something is wrong"); - return { - success: false, - message: "active user is empty.something is wrong", - }; + throw Error("error happened, active users is empty"); } await db.query("BEGIN"); @@ -91,10 +97,10 @@ export const updateDbUsers = async (extractedDir, db) => { await db.query("COMMIT"); logger.info("users inserted into database successfully"); - return { success: true }; + return true; } catch (error) { await db.query("ROLLBACK"); logger.error(error); - return { success: false }; + throw error; } }; diff --git a/api/functions/updateUsersActivity.js b/api/functions/updateUsersActivity.js index 661b73f..0d472eb 100644 --- a/api/functions/updateUsersActivity.js +++ b/api/functions/updateUsersActivity.js @@ -24,7 +24,9 @@ export const updateUsersActivity = async (processedActivity, db) => { try { if (!processedActivity || Object.keys(processedActivity).length === 0) { logger.error("invalid or empty activity data"); - return { success: false, message: "invalid or empty activity data" }; + + throw Error("invalid or empty activity data"); + // return { success: false, message: "invalid or empty activity data" }; } // retrieve all the users in database as they are real users @@ -70,7 +72,7 @@ export const updateUsersActivity = async (processedActivity, db) => { if (usersActivity.length === 0) { await db.query("COMMIT"); logger.warn("no user activity was found to be instrted."); - return { success: true }; + return true; } const insertQuery = ` @@ -94,10 +96,10 @@ export const updateUsersActivity = async (processedActivity, db) => { await db.query("COMMIT"); - return { success: true }; + return true; } catch (error) { await db.query("ROLLBACK"); logger.debug("Error inserting user activity:", error); - return { success: false, message: error.message }; + throw error; } }; diff --git a/api/package.json b/api/package.json index 34de6f0..4fcfc42 100644 --- a/api/package.json +++ b/api/package.json @@ -21,6 +21,8 @@ "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.9.0", "pg": "^8.13.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "winston": "^3.17.0" }, "devDependencies": { diff --git a/api/swagger.js b/api/swagger.js new file mode 100644 index 0000000..bf74ae6 --- /dev/null +++ b/api/swagger.js @@ -0,0 +1,30 @@ +import swaggerJSDoc from "swagger-jsdoc"; +import swaggerUi from "swagger-ui-express"; + +const swaggerOptions = { + definition: { + openapi: "3.0.0", + info: { + title: "Slack Dashboard API", + version: "1.0.0", + description: "API documentation for the Slack Dashboard project", + contact: { + name: "Behrouz Karimi", + email: "your-email@example.com", + }, + }, + servers: [ + { + url: "http://localhost:3000/api", + description: "Local dev server", + }, + ], + }, + apis: ["./api.js"], +}; + +const swaggerDocs = swaggerJSDoc(swaggerOptions); + +export default (app) => { + app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDocs)); +}; diff --git a/package-lock.json b/package-lock.json index 602cc3d..eb23b4a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -53,6 +53,8 @@ "multer": "^1.4.5-lts.1", "node-pg-migrate": "^7.9.0", "pg": "^8.13.1", + "swagger-jsdoc": "^6.2.8", + "swagger-ui-express": "^5.0.1", "winston": "^3.17.0" }, "devDependencies": { @@ -189,6 +191,50 @@ "react": ">=16.9.0" } }, + "node_modules/@apidevtools/json-schema-ref-parser": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-9.1.2.tgz", + "integrity": "sha512-r1w81DpR+KyRWd3f+rk6TNqMgedmAxZP5v5KWlXQWlgMUUtyEJch0DKEci1SorPMiSeM8XPl7MZ3miJ60JIpQg==", + "license": "MIT", + "dependencies": { + "@jsdevtools/ono": "^7.1.3", + "@types/json-schema": "^7.0.6", + "call-me-maybe": "^1.0.1", + "js-yaml": "^4.1.0" + } + }, + "node_modules/@apidevtools/openapi-schemas": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/@apidevtools/openapi-schemas/-/openapi-schemas-2.1.0.tgz", + "integrity": "sha512-Zc1AlqrJlX3SlpupFGpiLi2EbteyP7fXmUOGup6/DnkRgjP9bgMM/ag+n91rsv0U1Gpz0H3VILA/o3bW7Ua6BQ==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/@apidevtools/swagger-methods": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-methods/-/swagger-methods-3.0.2.tgz", + "integrity": "sha512-QAkD5kK2b1WfjDS/UQn/qQkbwF31uqRjPTrsCs5ZG9BQGAkjwvqGFjjPqAuzac/IYzpPtRzjCP1WrTuAIjMrXg==", + "license": "MIT" + }, + "node_modules/@apidevtools/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/@apidevtools/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-sNiLY51vZOmSPFZA5TF35KZ2HbgYklQnTSDnkghamzLb3EkNtcQnrBQEj5AOCxHpTtXpqMCRM1CrmV2rG6nw4g==", + "license": "MIT", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^9.0.6", + "@apidevtools/openapi-schemas": "^2.0.4", + "@apidevtools/swagger-methods": "^3.0.2", + "@jsdevtools/ono": "^7.1.3", + "call-me-maybe": "^1.0.1", + "z-schema": "^5.0.1" + }, + "peerDependencies": { + "openapi-types": ">=7" + } + }, "node_modules/@asamuzakjp/css-color": { "version": "2.8.3", "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-2.8.3.tgz", @@ -2266,6 +2312,12 @@ "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@jsdevtools/ono": { + "version": "7.1.3", + "resolved": "https://registry.npmjs.org/@jsdevtools/ono/-/ono-7.1.3.tgz", + "integrity": "sha512-4JQNk+3mVzK3xh2rqd6RB4J46qUR19azEHBneZyTZM+c456qOrbbM/5xcR8huNCCcbVt7+UmizG6GuUvPvKUYg==", + "license": "MIT" + }, "node_modules/@mswjs/interceptors": { "version": "0.37.6", "resolved": "https://registry.npmjs.org/@mswjs/interceptors/-/interceptors-0.37.6.tgz", @@ -2805,6 +2857,13 @@ "dev": true, "license": "MIT" }, + "node_modules/@scarf/scarf": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/@scarf/scarf/-/scarf-1.4.0.tgz", + "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", + "hasInstallScript": true, + "license": "Apache-2.0" + }, "node_modules/@sinclair/typebox": { "version": "0.27.8", "resolved": "https://registry.npmjs.org/@sinclair/typebox/-/typebox-0.27.8.tgz", @@ -3410,7 +3469,6 @@ "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", - "dev": true, "license": "MIT" }, "node_modules/@types/json5": { @@ -4318,7 +4376,6 @@ "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", - "dev": true, "license": "Python-2.0" }, "node_modules/aria-query": { @@ -4956,7 +5013,6 @@ "version": "1.1.11", "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, "license": "MIT", "dependencies": { "balanced-match": "^1.0.0", @@ -5174,6 +5230,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "license": "MIT" + }, "node_modules/callsites": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", @@ -5598,7 +5660,6 @@ "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", - "dev": true, "license": "MIT" }, "node_modules/concat-stream": { @@ -6274,7 +6335,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-3.0.0.tgz", "integrity": "sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==", - "dev": true, "license": "Apache-2.0", "dependencies": { "esutils": "^2.0.2" @@ -7515,7 +7575,6 @@ "version": "2.0.3", "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", - "dev": true, "license": "BSD-2-Clause", "engines": { "node": ">=0.10.0" @@ -8021,7 +8080,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", - "dev": true, "license": "ISC" }, "node_modules/fsevents": { @@ -8680,7 +8738,6 @@ "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", - "dev": true, "license": "ISC", "dependencies": { "once": "^1.3.0", @@ -10439,7 +10496,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", - "dev": true, "license": "MIT", "dependencies": { "argparse": "^2.0.1" @@ -10800,6 +10856,20 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.get": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", + "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==", + "deprecated": "This package is deprecated. Use the optional chaining (?.) operator instead.", + "license": "MIT" + }, + "node_modules/lodash.isequal": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", + "integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==", + "deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.", + "license": "MIT" + }, "node_modules/lodash.merge": { "version": "4.6.2", "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", @@ -10807,6 +10877,12 @@ "dev": true, "license": "MIT" }, + "node_modules/lodash.mergewith": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.mergewith/-/lodash.mergewith-4.6.2.tgz", + "integrity": "sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==", + "license": "MIT" + }, "node_modules/log-update": { "version": "6.1.0", "resolved": "https://registry.npmjs.org/log-update/-/log-update-6.1.0.tgz", @@ -11146,7 +11222,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, "license": "ISC", "dependencies": { "brace-expansion": "^1.1.7" @@ -11940,7 +12015,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -11971,6 +12045,13 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/openapi-types": { + "version": "12.1.3", + "resolved": "https://registry.npmjs.org/openapi-types/-/openapi-types-12.1.3.tgz", + "integrity": "sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==", + "license": "MIT", + "peer": true + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -12224,7 +12305,6 @@ "version": "1.0.1", "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.10.0" @@ -15050,6 +15130,101 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/swagger-jsdoc": { + "version": "6.2.8", + "resolved": "https://registry.npmjs.org/swagger-jsdoc/-/swagger-jsdoc-6.2.8.tgz", + "integrity": "sha512-VPvil1+JRpmJ55CgAtn8DIcpBs0bL5L3q5bVQvF4tAW/k/9JYSj7dCpaYCAv5rufe0vcCbBRQXGvzpkWjvLklQ==", + "license": "MIT", + "dependencies": { + "commander": "6.2.0", + "doctrine": "3.0.0", + "glob": "7.1.6", + "lodash.mergewith": "^4.6.2", + "swagger-parser": "^10.0.3", + "yaml": "2.0.0-1" + }, + "bin": { + "swagger-jsdoc": "bin/swagger-jsdoc.js" + }, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/swagger-jsdoc/node_modules/commander": { + "version": "6.2.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-6.2.0.tgz", + "integrity": "sha512-zP4jEKbe8SHzKJYQmq8Y9gYjtO/POJLgIdKgV7B9qNmABVFVc+ctqSX6iXh4mCpJfRBOabiZ2YKPg8ciDw6C+Q==", + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-jsdoc/node_modules/glob": { + "version": "7.1.6", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.1.6.tgz", + "integrity": "sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "license": "ISC", + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.0.4", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/swagger-jsdoc/node_modules/yaml": { + "version": "2.0.0-1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.0.0-1.tgz", + "integrity": "sha512-W7h5dEhywMKenDJh2iX/LABkbFnBxasD27oyXWDS/feDsxiw0dD5ncXdYXgkvAsXIY2MpW/ZKkr9IU30DBdMNQ==", + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/swagger-parser": { + "version": "10.0.3", + "resolved": "https://registry.npmjs.org/swagger-parser/-/swagger-parser-10.0.3.tgz", + "integrity": "sha512-nF7oMeL4KypldrQhac8RyHerJeGPD1p2xDh900GPvc+Nk7nWP6jX2FcC7WmkinMoAmoO774+AFXcWsW8gMWEIg==", + "license": "MIT", + "dependencies": { + "@apidevtools/swagger-parser": "10.0.3" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/swagger-ui-dist": { + "version": "5.21.0", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.21.0.tgz", + "integrity": "sha512-E0K3AB6HvQd8yQNSMR7eE5bk+323AUxjtCz/4ZNKiahOlPhPJxqn3UPIGs00cyY/dhrTDJ61L7C/a8u6zhGrZg==", + "license": "Apache-2.0", + "dependencies": { + "@scarf/scarf": "=1.4.0" + } + }, + "node_modules/swagger-ui-express": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/swagger-ui-express/-/swagger-ui-express-5.0.1.tgz", + "integrity": "sha512-SrNU3RiBGTLLmFU8GIJdOdanJTl4TOmT27tt3bWWHppqYmAZ6IDuEuBvMU6nZq0zLEe6b/1rACXCgLZqO6ZfrA==", + "license": "MIT", + "dependencies": { + "swagger-ui-dist": ">=5.0.0" + }, + "engines": { + "node": ">= v0.10.32" + }, + "peerDependencies": { + "express": ">=4.0.0 || >=5.0.0-beta" + } + }, "node_modules/symbol-tree": { "version": "3.2.4", "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", @@ -15726,6 +15901,15 @@ "dev": true, "license": "MIT" }, + "node_modules/validator": { + "version": "13.15.0", + "resolved": "https://registry.npmjs.org/validator/-/validator-13.15.0.tgz", + "integrity": "sha512-36B2ryl4+oL5QxZ3AzD0t5SsMNGvTtQHpjgFO5tbNxfXbMFkY822ktCDe1MnlqV3301QQI9SLHDNJokDI+Z9pA==", + "license": "MIT", + "engines": { + "node": ">= 0.10" + } + }, "node_modules/vary": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/vary/-/vary-1.1.2.tgz", @@ -16295,7 +16479,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { @@ -16477,6 +16660,36 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/z-schema": { + "version": "5.0.5", + "resolved": "https://registry.npmjs.org/z-schema/-/z-schema-5.0.5.tgz", + "integrity": "sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==", + "license": "MIT", + "dependencies": { + "lodash.get": "^4.4.2", + "lodash.isequal": "^4.5.0", + "validator": "^13.7.0" + }, + "bin": { + "z-schema": "bin/z-schema" + }, + "engines": { + "node": ">=8.0.0" + }, + "optionalDependencies": { + "commander": "^9.4.1" + } + }, + "node_modules/z-schema/node_modules/commander": { + "version": "9.5.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-9.5.0.tgz", + "integrity": "sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": "^12.20.0 || >=14" + } + }, "node_modules/zip-stream": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/zip-stream/-/zip-stream-6.0.1.tgz",