diff --git a/api/package-lock.json b/api/package-lock.json index fe89805..ac76d88 100644 --- a/api/package-lock.json +++ b/api/package-lock.json @@ -18,7 +18,8 @@ }, "devDependencies": { "concurrently": "^9.0.1", - "nodemon": "^3.1.7" + "nodemon": "^3.1.7", + "prettier": "^3.4.1" } }, "node_modules/accepts": { @@ -1535,6 +1536,22 @@ "node": ">=10" } }, + "node_modules/prettier": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.4.1.tgz", + "integrity": "sha512-G+YdqtITVZmOJje6QkXQWzl3fSfMxFwm1tjTyo9exhkmWSqC4Yhd1+lug++IlR2mvRVAxEDDWYkQdeSztajqgg==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, "node_modules/proxy-addr": { "version": "2.0.7", "resolved": "https://registry.npmjs.org/proxy-addr/-/proxy-addr-2.0.7.tgz", diff --git a/api/package.json b/api/package.json index 3ed6c2a..6f9570f 100644 --- a/api/package.json +++ b/api/package.json @@ -21,6 +21,7 @@ }, "devDependencies": { "concurrently": "^9.0.1", - "nodemon": "^3.1.7" + "nodemon": "^3.1.7", + "prettier": "^3.4.1" } } diff --git a/api/src/app.js b/api/src/app.js index 8b33886..7e53a76 100644 --- a/api/src/app.js +++ b/api/src/app.js @@ -42,7 +42,10 @@ app.use((error, req, res, next) => { console.error(error); res .status(error.statusCode || 500) - .json({ message: error.message || "Server Error", details: error.details }); + .json({ + message: error.message || "Server Error", + details: error.details || "None", + }); }); app.listen(PORT, () => { diff --git a/api/src/controllers/adminController.js b/api/src/controllers/adminController.js index 565f04e..4520808 100644 --- a/api/src/controllers/adminController.js +++ b/api/src/controllers/adminController.js @@ -1,12 +1,14 @@ /* Middleware to handle actions requiring admin authorization */ -const queries = require("../db/queries"); +const queries = require("../db/queries/adminQueries"); +const { selectAllFromTable } = require("../db/queries/appQueries"); const { BadRequestError } = require("../lib/errorsClasses"); +const parseMonthRange = require("../lib/parseMonthRange"); // Get all rows from a given table async function tableGet(req, res, next) { try { table = req.table; - const rows = await queries.selectAllFromTable(table); + const rows = await selectAllFromTable(table); return res.json({ message: "ok", rows }); } catch (error) { @@ -107,7 +109,7 @@ async function interactionsGet(req, res, next) { queries.selectInteractions(limit, offset), ]); - res.json({ message: "ok", total_rows, rows }); + return res.json({ message: "ok", total_rows, rows }); } catch (error) { return next(error); } @@ -123,6 +125,66 @@ async function countTable(req, res, next) { } } +async function adminReportGet(req, res, next) { + try { + const { startMonth, endMonth, category } = req.query; + if (!startMonth || !endMonth || !category) { + throw new BadRequestError("Start, end, and category must be provided"); + } + + if (!["type", "location", "format"].includes(category)) { + throw new BadRequestError("Invalid category provided"); + } + + // query database for cumulative interactions counts during range + const total_interactions = await queries.countInteractionsAdmin( + startMonth, + endMonth, + ); + const total_detailed = await queries.countInteractionByCategoryAdmin( + startMonth, + endMonth, + category, + ); + + // parse range between start and end month + const range = parseMonthRange(startMonth, endMonth); + const monthly_details = []; + // query database for each month in range + for (const month of range) { + const rows = await queries.countInteractionsByCategoryByMonth( + month, + category, + ); + + const monthObject = { + month, + }; + + // push data from each row to new formatted object + for (const row of rows) { + monthObject[row.value] = row.number_of_interactions; + } + + // push month object to monthly_details array + monthly_details.push(monthObject); + } + + const keys = Object.keys(monthly_details[0]); + + res.json({ + message: "ok", + range, + total_interactions, + total_detailed, + monthly_details, + keys, + }); + } catch (error) { + return next(error); + } +} + module.exports = { rowGetById, tableGet, @@ -131,4 +193,5 @@ module.exports = { statsGet, interactionsGet, countTable, + adminReportGet, }; diff --git a/api/src/controllers/appController.js b/api/src/controllers/appController.js index 99e444e..5e86718 100644 --- a/api/src/controllers/appController.js +++ b/api/src/controllers/appController.js @@ -2,7 +2,7 @@ Middleware to handle actions not requiring admin authorization */ -const queries = require("../db/queries"); +const queries = require("../db/queries/appQueries"); const { BadRequestError } = require("../lib/errorsClasses"); // get all rows from types, locations, and formats tables diff --git a/api/src/db/queries/adminQueries.js b/api/src/db/queries/adminQueries.js new file mode 100644 index 0000000..c2b4cb5 --- /dev/null +++ b/api/src/db/queries/adminQueries.js @@ -0,0 +1,157 @@ +const db = require("../connection"); +const { DatabaseError } = require("../../lib/errorsClasses"); + +// select paginated subsection of interactions table +async function selectInteractions(limit, offset) { + try { + return await db("interactions") + .select( + "interactions.id", + "types.value as type", + "locations.value as location", + "formats.value as format", + "date", + ) + .join("types", "interactions.type_id", "=", "types.id") + .join("locations", "interactions.location_id", "=", "locations.id") + .join("formats", "interactions.format_id", "=", "formats.id") + .limit(limit) + .offset(offset); + } catch (error) { + throw DatabaseError(error.message); + } +} + +// Select row by id from a given table +async function selectRowFromTable(table, id) { + try { + return await db(table).select("*").where("id", id).first(); + } catch (error) { + throw new DatabaseError(error.message); + } +} + +// Update row by id from a given table +async function updateRowFromTable(table, id, values) { + try { + const rows = await db(table).where("id", id).update(values, ["*"]); + return rows[0]; + } catch (error) { + throw new DatabaseError(error.message); + } +} + +// Insert new row into specified table +async function insertRow(table, row) { + try { + const rows = await db(table).insert(row, ["*"]); + return rows[0]; + } catch (error) { + throw new DatabaseError(error.message); + } +} + +// Count all rows in interactions table +async function countAllInteractions() { + try { + const count = await db("interactions") + .count("interactions.id as total") + .first(); + + return count.total; + } catch (error) { + throw new DatabaseError(error.message); + } +} + +// Count rows in interaction grouped by a given group +async function countAllInteractionByGroup(group) { + try { + return await db(`${group}s`) + .select(`${group}s.id`, `${group}s.value`) + .count("interactions.id as total_interactions") + .leftJoin("interactions", `${group}s.id`, "=", `interactions.${group}_id`) + .groupBy(`${group}s.id`); + } catch (error) { + throw new DatabaseError(error.message); + } +} + +async function countRowsInTable(table) { + try { + const rows = await db.raw(`SELECT COUNT(id) AS total_rows FROM :table:`, { + table, + }); + + return rows[0].total_rows; + } catch (error) { + throw new DatabaseError(error.message); + } +} + +async function countInteractionsAdmin(start, end) { + const rows = await db("interactions") + .count("* as total_interactions") + .whereRaw("strftime('%Y-%m', date) BETWEEN :start AND :end", { + start, + end, + }) + .first(); + + return rows.total_interactions; +} + +async function countInteractionByCategoryAdmin(start, end, category) { + const table = `${category}s`; + + const row = await db(table) + .select(`${table}.id`, `${table}.value`) + .count("interactions.id as number_of_interactions") + .leftJoin("interactions", `${table}.id`, `interactions.${category}_id`) + .whereRaw("strftime('%Y-%m', date) BETWEEN :start AND :end", { start, end }) + .groupBy(`${table}.id`); + + const rows = await db(table) + .select(`${table}.id`, `${table}.value`) + .count("interactions.id as number_of_interactions") + .leftJoin( + db.raw( + "interactions ON ??=?? AND strftime('%Y-%m', interactions.date) BETWEEN ? AND ?", + [`${table}.id`, `interactions.${category}_id`, start, end], + ), + ) + .groupBy(`${table}.id`); + + return rows; +} + +async function countInteractionsByCategoryByMonth(month, category) { + try { + const table = `${category}s`; + + return await db(table) + .select(`${table}.id`, `${table}.value`) + .count("interactions.id as number_of_interactions") + .leftJoin( + db.raw( + "interactions ON ??=?? AND strftime('%Y-%m', interactions.date)=?", + [`interactions.${category}_id`, `${table}.id`, month], + ), + ) + .groupBy(`${table}.id`); + } catch (error) { + throw new DatabaseError(error.message); + } +} + +module.exports = { + selectInteractions, + selectRowFromTable, + updateRowFromTable, + insertRow, + countAllInteractionByGroup, + countRowsInTable, + countInteractionsAdmin, + countInteractionByCategoryAdmin, + countInteractionsByCategoryByMonth, +}; diff --git a/api/src/db/queries.js b/api/src/db/queries/appQueries.js similarity index 61% rename from api/src/db/queries.js rename to api/src/db/queries/appQueries.js index 6039ede..377a88d 100644 --- a/api/src/db/queries.js +++ b/api/src/db/queries/appQueries.js @@ -1,26 +1,6 @@ -const db = require("./connection"); -const { getDateToday } = require("../lib/dates"); -const { DatabaseError } = require("../lib/errorsClasses"); - -async function selectInteractions(limit, offset) { - try { - return await db("interactions") - .select( - "interactions.id", - "types.value as type", - "locations.value as location", - "formats.value as format", - "date", - ) - .join("types", "interactions.type_id", "=", "types.id") - .join("locations", "interactions.location_id", "=", "locations.id") - .join("formats", "interactions.format_id", "=", "formats.id") - .limit(limit) - .offset(offset); - } catch (error) { - throw DatabaseError(error.message); - } -} +const db = require("../connection"); +const { getDateToday } = require("../../lib/dates"); +const { DatabaseError } = require("../../lib/errorsClasses"); // Select all columns from given table async function selectAllFromTable(table) { @@ -151,75 +131,7 @@ async function countInteractionsThisMonth() { } } -// Select row by id from a given table -async function selectRowFromTable(table, id) { - try { - return await db(table).select("*").where("id", id).first(); - } catch (error) { - throw new DatabaseError(error.message); - } -} - -// Update row by id from a given table -async function updateRowFromTable(table, id, values) { - try { - const rows = await db(table).where("id", id).update(values, ["*"]); - return rows[0]; - } catch (error) { - throw new DatabaseError(error.message); - } -} - -// Insert new row into specified table -async function insertRow(table, row) { - try { - const rows = await db(table).insert(row, ["*"]); - return rows[0]; - } catch (error) { - throw new DatabaseError(error.message); - } -} - -// Count all rows in interactions table -async function countAllInteractions() { - try { - const count = await db("interactions") - .count("interactions.id as total") - .first(); - - return count.total; - } catch (error) { - throw new DatabaseError(error.message); - } -} - -// Count rows in interaction grouped by a given group -async function countAllInteractionByGroup(group) { - try { - return await db(`${group}s`) - .select(`${group}s.id`, `${group}s.value`) - .count("interactions.id as total_interactions") - .leftJoin("interactions", `${group}s.id`, "=", `interactions.${group}_id`) - .groupBy(`${group}s.id`); - } catch (error) { - throw new DatabaseError(error.message); - } -} - -async function countRowsInTable(table) { - try { - const rows = await db.raw(`SELECT COUNT(id) AS total_rows FROM :table:`, { - table, - }); - - return rows[0].total_rows; - } catch (error) { - throw new DatabaseError(error.message); - } -} - module.exports = { - selectInteractions, selectAllFromTable, insertInteraction, checkIfExists, @@ -228,10 +140,4 @@ module.exports = { countInteractionsByCategory, countInteractionsByDay, countInteractionsThisMonth, - selectRowFromTable, - updateRowFromTable, - insertRow, - countAllInteractionByGroup, - countRowsInTable, - // countAllInteractions, }; diff --git a/api/src/lib/parseMonthRange.js b/api/src/lib/parseMonthRange.js new file mode 100644 index 0000000..bf918a9 --- /dev/null +++ b/api/src/lib/parseMonthRange.js @@ -0,0 +1,40 @@ +const { BadRequestError } = require("./errorsClasses"); + +function parseMonthRange(startMonth, endMonth) { + // Parse inputs to extract year and month + const [startYear, startMonthNum] = startMonth.split("-").map(Number); + const [endYear, endMonthNum] = endMonth.split("-").map(Number); + + const dateMonthsArray = []; + let currentYear = startYear; + let currentMonth = startMonthNum; + + // Validate inputs + if ( + startYear > endYear || + (startYear === endYear && startMonthNum > endMonthNum) + ) { + throw new BadRequestError( + "Start month must be before or equal to the end month.", + ); + } + + while ( + currentYear < endYear || + (currentYear === endYear && currentMonth <= endMonthNum) + ) { + const formattedMonth = String(currentMonth).padStart(2, "0"); // Ensure 2-digit month + dateMonthsArray.push(`${currentYear}-${formattedMonth}`); + + // Increment month and adjust year if needed + currentMonth++; + if (currentMonth > 12) { + currentMonth = 1; + currentYear++; + } + } + + return dateMonthsArray; +} + +module.exports = parseMonthRange; diff --git a/api/src/routes/adminRoutes.js b/api/src/routes/adminRoutes.js index 43fff51..21e4408 100644 --- a/api/src/routes/adminRoutes.js +++ b/api/src/routes/adminRoutes.js @@ -29,8 +29,12 @@ router.get("/formats/:id", adminController.updateRowById); router.post("/formats", adminController.addNewRow); router.get("/formats", adminController.tableGet); +// todo: rename to a more semantic route and controller name. (i.e /admin/summary/count?) router.get("/stats", adminController.statsGet); +// todo: rename to a more semantic route and controller name. (i.e /admin/summary/interactions?) router.get("/interactions", adminController.interactionsGet); router.get("/count/:table", adminController.countTable); +router.get("/report", adminController.adminReportGet); + module.exports = router; diff --git a/client/src/Admin.jsx b/client/src/Admin.jsx index 3eec2ab..ba7d89d 100644 --- a/client/src/Admin.jsx +++ b/client/src/Admin.jsx @@ -59,6 +59,7 @@ function Admin() { navItems={[ { label: "Back to App", route: "/" }, { label: "Dashboard", route: "/admin" }, + { label: "Reporting", route: "/admin/reporting" }, { label: "Database", route: "/admin/database" }, ]} /> diff --git a/client/src/components/Button.jsx b/client/src/components/Button.jsx index 49d9adb..fa3bf01 100644 --- a/client/src/components/Button.jsx +++ b/client/src/components/Button.jsx @@ -1,9 +1,15 @@ import PropTypes from "prop-types"; import styles from "./Button.module.css"; -/* +/** * Custom button component - * Variant corresponds to styles in Button.module.css + * @param {string} [id] - prop to set `data-id` property of button + * @param {string} text - button text property + * @param {(e: Event) => void} [action] - button click callback function + * @param {'primary' | 'danger'} variant - styled button variant + * @param {'button' | 'submit'} type + * @param {Object} [style] - Optional React style object + * @returns {JSX.Element} */ function Button({ id, text, action, variant, type, style }) { @@ -23,8 +29,8 @@ function Button({ id, text, action, variant, type, style }) { Button.propTypes = { id: PropTypes.string, text: PropTypes.string.isRequired, - type: PropTypes.string.isRequired, - variant: PropTypes.string.isRequired, + type: PropTypes.oneOf(["button", "submit"]).isRequired, + variant: PropTypes.oneOf(["primary", "danger"]).isRequired, action: PropTypes.func, style: PropTypes.object, }; diff --git a/client/src/components/CardWrapper.jsx b/client/src/components/CardWrapper.jsx index 75e19ac..ce2d984 100644 --- a/client/src/components/CardWrapper.jsx +++ b/client/src/components/CardWrapper.jsx @@ -1,7 +1,12 @@ import styles from "./CardWrapper.module.css"; import PropTypes from "prop-types"; -/* Wrapper to provide UI styling to elements */ +/** + * Wrapper to provide UI styling to children + * @param {React.ReactNode} children + * @param {Object} [style] - optional React inline styles + * @returns {JSX.Element} + */ function CardWrapper({ children, style }) { return ( diff --git a/client/src/components/CountReport.jsx b/client/src/components/CountReport.jsx index 714669a..597d067 100644 --- a/client/src/components/CountReport.jsx +++ b/client/src/components/CountReport.jsx @@ -8,6 +8,13 @@ import styles from "./CountReport.module.css"; * id, value, number_of_interactions */ +/** + * Component used to display count summary on app report page + * @param {string} title - report title + * @param {{id: number, value: string, number_of_interaction: number}} count - report from database + * @returns {JSX.Element} + */ + function CountReport({ title, count }) { return (
diff --git a/client/src/components/DateInput.jsx b/client/src/components/DateInput.jsx index d7943aa..7fe153c 100644 --- a/client/src/components/DateInput.jsx +++ b/client/src/components/DateInput.jsx @@ -3,16 +3,27 @@ import styles from "./DateInput.module.css"; /* Custom date input with label and callback */ -function DateInput({ label, value, handleChange }) { - const id = label.toLowerCase(); +/** + * Custom date input with label and callback + * @param {string} id + * @param {string} label + * @param {'date' | 'month'} type + * @param {string} value + * @param {(e: Event) => void} handleChange + * @returns {JSX.Element} + */ +function DateInput({ id, label, type, value, handleChange }) { return (
- + @@ -23,7 +34,9 @@ function DateInput({ label, value, handleChange }) { export default DateInput; DateInput.propTypes = { + id: PropTypes.string.isRequired, label: PropTypes.string.isRequired, + type: PropTypes.oneOf(["date", "month"]).isRequired, value: PropTypes.string.isRequired, handleChange: PropTypes.func.isRequired, }; diff --git a/client/src/components/ErrorComponent.jsx b/client/src/components/ErrorComponent.jsx index 31df3c4..f92cba2 100644 --- a/client/src/components/ErrorComponent.jsx +++ b/client/src/components/ErrorComponent.jsx @@ -1,6 +1,11 @@ import PropTypes from "prop-types"; -/* Error message page */ +/** + * Custom error component used throughout app + * @param {string} status - HTTP status code represented as a string + * @returns {JSX.Element} + * @constructor + */ function ErrorComponent({ status }) { return ( diff --git a/client/src/components/Form.jsx b/client/src/components/Form.jsx index 7d72076..fd6a836 100644 --- a/client/src/components/Form.jsx +++ b/client/src/components/Form.jsx @@ -1,6 +1,16 @@ import PropTypes from "prop-types"; import styles from "./Form.module.css"; +/** + * Custom form component used to render and display form elements + * @param {(e: Event) => void} [onSubmit] - callback function to run when form is submitted + * @param {string} [title] - optional form title + * @param {Object} [style] - optional React inline styles + * @param {React.ReactNode} children + * @returns {JSX.Element} + * @constructor + */ + function Form({ onSubmit, title, style, children }) { return (
diff --git a/client/src/components/Modal.jsx b/client/src/components/Modal.jsx index aac1e03..276ecdc 100644 --- a/client/src/components/Modal.jsx +++ b/client/src/components/Modal.jsx @@ -3,7 +3,15 @@ import PropTypes from "prop-types"; import styles from "./Modal.module.css"; import Button from "./Button"; -/* Utility modal component */ +/** + * Custom modal component + * @param {boolean} isOpen + * @param {(boolean) => void} setIsOpen + * @param {React.ReactNode} children + * @param {Object} [style] + * @returns {JSX.Element} + * @constructor + */ function Modal({ isOpen, setIsOpen, children, style }) { const modalRef = useRef(null); diff --git a/client/src/components/Nav.jsx b/client/src/components/Nav.jsx index 9216557..78484e7 100644 --- a/client/src/components/Nav.jsx +++ b/client/src/components/Nav.jsx @@ -2,7 +2,12 @@ import { NavLink } from "react-router-dom"; import PropTypes from "prop-types"; import styles from "./Nav.module.css"; -/* App navigation menu */ +/** + * Add navigation menu component + * @param {Array<{label: string, route: string}>} navItems + * @returns {JSX.Element} + * @constructor + */ function Nav({ navItems }) { return ( diff --git a/client/src/components/SelectInput.jsx b/client/src/components/SelectInput.jsx index 10e5db8..fb9d7fb 100644 --- a/client/src/components/SelectInput.jsx +++ b/client/src/components/SelectInput.jsx @@ -1,16 +1,23 @@ import PropTypes from "prop-types"; import styles from "./SelectInput.module.css"; -/* Custom select input with label, options, and callback */ +/** + * Custom select input with id, label, options, and a callback + * @param {string} id + * @param {string} label + * @param {Array<{id: number | string, value: string}>}options + * @param {(e: Event) => void} handleChange + * @param {string} value + * @returns {JSX.Element} + * @constructor + */ -function SelectInput({ label, options, handleChange, value }) { +function SelectInput({ id, label, options, handleChange, value }) { const style = { display: "flex", flexDirection: "column", }; - const id = label.toLowerCase(); - return (