diff --git a/.DS_Store b/.DS_Store deleted file mode 100644 index 5b928bb7..00000000 Binary files a/.DS_Store and /dev/null differ diff --git a/.gitignore b/.gitignore index 41635879..8cb6f767 100644 --- a/.gitignore +++ b/.gitignore @@ -2,12 +2,17 @@ build/ logs/ node_modules/ +__pycache__/ # Specific Files config.json service-account-credentials.json *.pem +# Database files +*.db-shm +*.db-wal + # File Types *.env *.zip diff --git a/package-lock.json b/package-lock.json index 826744f3..a0f6b385 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,6 +9,7 @@ "version": "1.0.0", "license": "ISC", "dependencies": { + "better-sqlite3": "^12.4.1", "dotenv": "^16.4.7", "express": "^4.21.2", "firebase-admin": "^13.1.0", @@ -20,6 +21,8 @@ "lru-cache": "^11.0.2", "node-schedule": "^2.1.1", "request": "^2.88.2", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.3", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "swagger-ui-express": "^5.0.1", @@ -399,6 +402,12 @@ "integrity": "sha512-xxeapPiUXdZAE3che6f3xogoJPeZgig6omHEy1rIY5WVsB3H2BHNnZH+gHG6x91SCWyQCzWGsuL2Hh3ClO5/qQ==", "hasInstallScript": true }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@tootallnate/once": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", @@ -431,6 +440,15 @@ "@types/node": "*" } }, + "node_modules/@types/cors": { + "version": "2.8.19", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.19.tgz", + "integrity": "sha512-mFNylyeyqN93lfe/9CSxOGREz8cpzAhH+E93xJ4xWQf62V8sQ/24reV2nyzUWM6H6Xji+GGHpkbLe7pVoUEskg==", + "license": "MIT", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/express": { "version": "4.17.21", "resolved": "https://registry.npmjs.org/@types/express/-/express-4.17.21.tgz", @@ -783,6 +801,15 @@ } ] }, + "node_modules/base64id": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/base64id/-/base64id-2.0.0.tgz", + "integrity": "sha512-lGe34o6EHj9y3Kts9R4ZYs/Gr+6N7MCaMlIFA3F1R2O5/m7K06AxfSeO5530PEERE6/WyEg3lsuyw4GHlPZHog==", + "license": "MIT", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/bcrypt-pbkdf": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/bcrypt-pbkdf/-/bcrypt-pbkdf-1.0.2.tgz", @@ -791,6 +818,20 @@ "tweetnacl": "^0.14.3" } }, + "node_modules/better-sqlite3": { + "version": "12.4.1", + "resolved": "https://registry.npmjs.org/better-sqlite3/-/better-sqlite3-12.4.1.tgz", + "integrity": "sha512-3yVdyZhklTiNrtg+4WqHpJpFDd+WHTg2oM7UcR80GqL05AOV0xEJzc6qNvFYoEtE+hRp1n9MpN6/+4yhlGkDXQ==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "bindings": "^1.5.0", + "prebuild-install": "^7.1.1" + }, + "engines": { + "node": "20.x || 22.x || 23.x || 24.x" + } + }, "node_modules/bignumber.js": { "version": "9.1.2", "resolved": "https://registry.npmjs.org/bignumber.js/-/bignumber.js-9.1.2.tgz", @@ -1165,6 +1206,19 @@ "resolved": "https://registry.npmjs.org/core-util-is/-/core-util-is-1.0.2.tgz", "integrity": "sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==" }, + "node_modules/cors": { + "version": "2.8.5", + "resolved": "https://registry.npmjs.org/cors/-/cors-2.8.5.tgz", + "integrity": "sha512-KIHbLJqu73RGr/hnbrO9uBeixNGuvSQjul/jdFvS/KFSIH1hWVd1ng7zOHx+YrEfInLG7q4n6GHQ9cDtxv/P6g==", + "license": "MIT", + "dependencies": { + "object-assign": "^4", + "vary": "^1" + }, + "engines": { + "node": ">= 0.10" + } + }, "node_modules/cron-parser": { "version": "4.9.0", "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz", @@ -1379,6 +1433,124 @@ "once": "^1.4.0" } }, + "node_modules/engine.io": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz", + "integrity": "sha512-ZCkIjSYNDyGn0R6ewHDtXgns/Zre/NT6Agvq1/WobF7JXgFff4SeDroKiCO3fNJreU9YG429Sc81o4w5ok/W5g==", + "license": "MIT", + "dependencies": { + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.7.2", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-client": { + "version": "6.6.4", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.4.tgz", + "integrity": "sha512-+kjUJnZGwzewFDw951CDWcwj35vMNf2fcj7xQWOctq1F2i1jkDdVvdFG9kM/BEChymCH36KgjnW0NsL58JYRxw==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.18.3", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/engine.io-client/node_modules/ws": { + "version": "8.18.3", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.18.3.tgz", + "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/engine.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/env-paths": { "version": "2.2.1", "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", @@ -3338,6 +3510,15 @@ "node": "*" } }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/object-inspect": { "version": "1.13.3", "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.3.tgz", @@ -4004,6 +4185,154 @@ "npm": ">= 3.0.0" } }, + "node_modules/socket.io": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.8.1.tgz", + "integrity": "sha512-oZ7iUCxph8WYRHHcjBEc9unw3adt5CmSNlppj/5Q4k2RIrhl8Z5yY2Xr4j9zj0+wzVZ0bxmYoGSzKJnRl6A4yg==", + "license": "MIT", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.6.0", + "socket.io-adapter": "~2.5.2", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/socket.io-adapter": { + "version": "2.5.5", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.5.tgz", + "integrity": "sha512-eLDQas5dzPgOWCk9GuuJC2lBqItuhKI4uxGgo9aIV7MYbk2h9Q6uULEh8WBzThoI7l+qU9Ast9fVUmkqPP9wYg==", + "license": "MIT", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.17.1" + } + }, + "node_modules/socket.io-adapter/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-adapter/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-client": { + "version": "4.8.3", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.3.tgz", + "integrity": "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.4.1", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-client/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/socket.io/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/socks": { "version": "2.8.4", "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.4.tgz", @@ -4682,6 +5011,27 @@ "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, "node_modules/xmlbuilder": { "version": "8.2.2", "resolved": "https://registry.npmjs.org/xmlbuilder/-/xmlbuilder-8.2.2.tgz", @@ -4690,6 +5040,14 @@ "node": ">=4.0" } }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/y18n": { "version": "5.0.8", "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", diff --git a/package.json b/package.json index 057904ac..c3817400 100644 --- a/package.json +++ b/package.json @@ -6,12 +6,15 @@ "type": "module", "scripts": { "start:dev": "nodemon --ignore src/data/notifRequests.json src/index.js", - "start": "node src/index.js" + "start": "node src/index.js", + "migrate": "node src/data/scripts/run-migrations.js", + "populate:db": "npm run migrate && python3 src/data/scripts/populate_db.py" }, "keywords": [], "author": "", "license": "ISC", "dependencies": { + "better-sqlite3": "^12.4.1", "dotenv": "^16.4.7", "express": "^4.21.2", "firebase-admin": "^13.1.0", @@ -23,6 +26,8 @@ "lru-cache": "^11.0.2", "node-schedule": "^2.1.1", "request": "^2.88.2", + "socket.io": "^4.8.1", + "socket.io-client": "^4.8.3", "sqlite": "^5.1.1", "sqlite3": "^5.1.7", "swagger-ui-express": "^5.0.1", diff --git a/src/.DS_Store b/src/.DS_Store index a39c96c4..807e9445 100644 Binary files a/src/.DS_Store and b/src/.DS_Store differ diff --git a/src/controllers/EventFormsController.js b/src/controllers/EventFormsController.js new file mode 100644 index 00000000..6d923756 --- /dev/null +++ b/src/controllers/EventFormsController.js @@ -0,0 +1,77 @@ +import express from "express"; +import { createEventForm, getAllEventForms, updateEventForm, getApprovedEventForms, toPublicEvent } from "../utils/EventFormsUtils.js"; + +const router = express.Router(); + +// Create an event form +router.post("/events/create-event", async (req, res) => { + try { + const { netid, name, eventType, startDate, endDate, organizationName, location, about } = req.body; + const eventForm = await createEventForm({ netid, name, eventType, startDate, endDate, organizationName, location, about }); + + // Broadcast a notification to the admin room that the event form has been created + const io = req.app.get("io"); + io.to("admin").emit("eventForm:new", {message: "Event request submitted", event: eventForm}); + + // Return the event form to the requesting client + res.status(201).json({ success: true, message: "Your event request has been submitted", data: toPublicEvent(eventForm)}); + } catch (error) { + console.error("Error creating event form:", error.message); + res.status(400).json({ success: false, message: "Error submitting event request", error: error.message }); + } +}); + +// Get all event forms +router.get("/events/", async (req, res) => { + try { + const eventForms = await getAllEventForms(); + res.status(200).json({ success: true, message: "All event requests retrieved successfully", data: eventForms.map(toPublicEvent)}); + } catch (error) { + console.error("Error getting all event forms:", error.message); + res.status(400).json({ success: false, message: "Error getting all event requests", error: error.message }); + } +}); + +// Update an event form +// NOTE: Only admins can update event forms +// NOTE: id is the event form's id, stored as the primary key in the database +router.put("/events/:id", async (req, res) => { + try { + const { id } = req.params; // id is found in the url path + const { approvalStatus } = req.body; + + // Initalize the io instance + const io = req.app.get("io"); + + // Update the event form in the database + const eventForm = await updateEventForm({ id: parseInt(id), approvalStatus }); + + // Handle event approval (currentl assumes that an update is only for approval or rejection, excludes pending) + if (approvalStatus === "approved") { + // Send a notification to everyone (and the admin room) + io.to("public").emit("eventForm:update", {message: "Event approved", event: toPublicEvent(eventForm)}); + } else { + // Send a notification to the submitting user (and the admin room) that the event was rejected + io.to(`netid:${eventForm.netid}`).emit("eventForm:update", {message: "Your event request has been rejected", event: toPublicEvent(eventForm)}); + } + + // Return the event form to the admin client that requested the update + res.status(200).json({ success: true, message: "Event request updated successfully", data: eventForm}); + } catch (error) { + console.error("Error updating event form:", error.message); + res.status(400).json({ success: false, message: "Error updating event request", error: error.message }); + } +}); + +// Get all approved event forms +router.get("/events/approved", async (req, res) => { + try { + const eventForms = await getApprovedEventForms(); + res.status(200).json({ success: true, message: "All approved event requests retrieved successfully", data: eventForms.map(toPublicEvent)}); + } catch (error) { + console.error("Error getting all approved event requests:", error.message); + res.status(400).json({ success: false, message: "Error getting all approved event requests", error: error.message }); + } +}); + +export default router; \ No newline at end of file diff --git a/src/data/db/database.py b/src/data/db/database.py index efdf541e..9b857b2a 100644 --- a/src/data/db/database.py +++ b/src/data/db/database.py @@ -32,18 +32,54 @@ def insert_library(location, address, latitude, longitude): conn.close() -def insert_printer(location, description, latitude, longitude): +def insert_printer(location, description, labels, latitude, longitude): """Insert a printer into the database.""" conn = get_db_connection() cursor = conn.cursor() + # We remove the "OR IGNORE" because we acknoledge that several printers may have the same location and description (i.e., same building and room), so we rely on the unique printer_id to identify the printer cursor.execute( """ - INSERT OR IGNORE INTO printers (location, description, latitude, longitude) + INSERT INTO printers (location, description, latitude, longitude) VALUES (?, ?, ?, ?) """, (location, description, latitude, longitude), ) + # To get the printer_id, we do NOT rely on the location/description/coordinates, but rather on the printer_id that was just inserted (lastrowid), as several printers may have the same location and description (i.e., same building and room) + printer_id = cursor.lastrowid + + # Insert labels into the labels table and get their IDs + label_ids = [] + for label in labels: + cursor.execute( + """ + INSERT OR IGNORE INTO labels (label) + VALUES (?) + """, + (label,), + ) + cursor.execute( + """ + SELECT id FROM labels WHERE label = ? + """, + (label,), + ) + result = cursor.fetchone() + if result is None: + raise ValueError(f"Failed to find label: {label}") + label_id = result[0] + label_ids.append(label_id) + + # Insert into junction table + for label_id in label_ids: + cursor.execute( + """ + INSERT OR IGNORE INTO printer_labels (printer_id, label_id) + VALUES (?, ?) + """, + (printer_id, label_id), + ) + conn.commit() conn.close() diff --git a/src/data/db/models.py b/src/data/db/models.py index 7634fd0e..8183be91 100644 --- a/src/data/db/models.py +++ b/src/data/db/models.py @@ -31,7 +31,7 @@ def create_tables(): """ CREATE TABLE IF NOT EXISTS printers ( id INTEGER PRIMARY KEY AUTOINCREMENT, - location TEXT UNIQUE, + location TEXT, description TEXT, latitude REAL, longitude REAL diff --git a/src/data/migrations/20251112_1755_create_event_form.sql b/src/data/migrations/20251112_1755_create_event_form.sql new file mode 100644 index 00000000..2582ff9c --- /dev/null +++ b/src/data/migrations/20251112_1755_create_event_form.sql @@ -0,0 +1,16 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS event_forms ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + name TEXT NOT NULL, + netid TEXT NOT NULL, + event_type TEXT NOT NULL, + start_date DATETIME, + end_date DATETIME, + organization_name TEXT, + about TEXT, + location TEXT NOT NULL, + approval_status TEXT NOT NULL DEFAULT 'pending' CHECK(approval_status IN ('pending', 'approved', 'rejected')), + created_at DATETIME DEFAULT CURRENT_TIMESTAMP, + updated_at DATETIME DEFAULT CURRENT_TIMESTAMP +); \ No newline at end of file diff --git a/src/data/migrations/2025117_1854_create_labels.sql b/src/data/migrations/2025117_1854_create_labels.sql new file mode 100644 index 00000000..3884e988 --- /dev/null +++ b/src/data/migrations/2025117_1854_create_labels.sql @@ -0,0 +1,6 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS labels ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + label TEXT UNIQUE NOT NULL +); \ No newline at end of file diff --git a/src/data/migrations/2025117_1859_create_printer_labels.sql b/src/data/migrations/2025117_1859_create_printer_labels.sql new file mode 100644 index 00000000..73fd9c06 --- /dev/null +++ b/src/data/migrations/2025117_1859_create_printer_labels.sql @@ -0,0 +1,9 @@ +PRAGMA foreign_keys = ON; + +CREATE TABLE IF NOT EXISTS printer_labels ( + printer_id INTEGER NOT NULL, + label_id INTEGER NOT NULL, + PRIMARY KEY (printer_id, label_id), + FOREIGN KEY (printer_id) REFERENCES printers(id) ON DELETE CASCADE, + FOREIGN KEY (label_id) REFERENCES labels(id) ON DELETE CASCADE +); \ No newline at end of file diff --git a/src/data/migrations/20251226_1106_remove_unique_location_printers.sql b/src/data/migrations/20251226_1106_remove_unique_location_printers.sql new file mode 100644 index 00000000..5c024c50 --- /dev/null +++ b/src/data/migrations/20251226_1106_remove_unique_location_printers.sql @@ -0,0 +1,23 @@ +PRAGMA foreign_keys = OFF; + +-- Step 1: Create a new printers table (match old schema except for the UNIQUE constraint on location) +CREATE TABLE printers_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + location TEXT, + description TEXT, + latitude REAL, + longitude REAL +); + +-- Step 2: Copy data from the old table to the new table (only if old table exists and has data) +INSERT INTO printers_new (id, location, description, latitude, longitude) +SELECT id, location, description, latitude, longitude +FROM printers; + +-- Step 3: Drop the old table +DROP TABLE IF EXISTS printers; + +-- Step 4: Rename the new table to the original name +ALTER TABLE printers_new RENAME TO printers; + +PRAGMA foreign_keys = ON; \ No newline at end of file diff --git a/src/data/migrations/20251227_2312_create_trigger_event_forms.sql b/src/data/migrations/20251227_2312_create_trigger_event_forms.sql new file mode 100644 index 00000000..c8921ffc --- /dev/null +++ b/src/data/migrations/20251227_2312_create_trigger_event_forms.sql @@ -0,0 +1,8 @@ +CREATE TRIGGER trg_event_forms_updated_at +AFTER UPDATE ON event_forms +FOR EACH ROW +BEGIN + UPDATE event_forms + SET updated_at = CURRENT_TIMESTAMP + WHERE id = OLD.id; +END; \ No newline at end of file diff --git a/src/data/scrapers/printers.py b/src/data/scrapers/printers.py index e972046f..ea40cd69 100644 --- a/src/data/scrapers/printers.py +++ b/src/data/scrapers/printers.py @@ -1,37 +1,292 @@ import requests -from bs4 import BeautifulSoup +from difflib import get_close_matches # For data scraping +from difflib import SequenceMatcher +import re # For using regex +import unicodedata # Handles text encoding at Unicode level # URL of the CU Print directory page -URL = "https://www.cornell.edu/about/maps/directory/?layer=CUPrint&caption=%20CU%20Print%20Printers" # Replace with the actual URL +# URL = "https://www.cornell.edu/about/maps/directory/?layer=CUPrint&caption=%20CU%20Print%20Printers" # Replace with the actual URL -def scrape_printers(): - # Send a GET request to fetch the HTML content - response = requests.get(URL) - soup = BeautifulSoup(response.text, 'html.parser') +URL = 'https://www.cornell.edu/about/maps/directory/text-data.cfm?layer=CUPrint&caption=%20CU%20Print%20Printers' + +# HTTP headers to mimic a real browser request +HEADERS = { + "User-Agent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36", + "Referer": 'https://www.cornell.edu/about/maps/directory/', + "X-Requested-With": 'XMLHttpRequest', + "Accept": 'application/json, text/javascript, */*', +} + +# Canonical list of Cornell buildings +# NOTE: This list is not exhaustive. Add more buildings as needed... +CANONICAL_BUILDINGS = [ + "Akwe:kon", + "Alice Cook House", + "Baker Lab", + "Barton Hall", + "Becker House", + "Breazzano Center", + "Catherwood Library", + "Clark Hall", + "College of Veterinary Medicine", + "Court-Kay-Bauer Hall", + "Dickson", + "Ecology House", + "Flora Rose House", + "Ganedago", + "Hans Bethe House", + "Hollister Hall", + "Ives Hall", + "John Henrik Clarke Africana Library", + "Keeton House", + "Kroch Library", + "Latino Living Center", + "Law Library", + "Lincoln Hall", + "Mann Library", + "Martha Van Rensselaer Hall", + "Mary Donlon Hall", + "Math Library", + "Mews Hall", + "Milstein Hall", + "Morrison Hall", + "Myron Taylor", + "Olin Library", + "Phillips Hall", + "Plant Science", + "RPCC", + "Rand Hall", + "Rhodes Hall", + "Risley Hall", + "Rockefeller Lab", + "Ruth Bader Ginsburg Hall", + "Sage Hall", + "Schwartz Center", + "Sibley Hall", + "Statler Hall", + "Stimson", + "Tjaden Hall", + "Toni Morrison", + "Ujamaa", + "Upson Hall", + "Uris Library", + "Vet Library", + "Warren Hall", + "White Hall", + "Willard Student Center" +] + +# Regex helpers +HTML_TAG_RE = re.compile(r"<[^>]+>") +BRACKET_CONTENT_RE = re.compile(r"[\(\[\{].*?[\)\]\}]") +MULTI_SPACE_RE = re.compile(r"\s+") +TRAILING_CAPS_RE = re.compile(r"\b[A-Z]{2,}(?:\s+[A-Z]{2,})*\s*$") + +# Used for stripping common label phrases from building names +LABEL_PHRASES_RE = re.compile( + r""" + \bresidents?\s*only\b | + \bstudents?\s*only\b | + \baa\s*&\s*p\b | + \baap\b + """, re.IGNORECASE | re.VERBOSE +) + +# Used to identify common variants of labels +LABEL_PATTERNS = { + # --- Access restrictions --- + # Residents Only (singular/plural + optional hyphen + any case) + "Residents Only": re.compile(r"\bresident[s]?[-\s]*only\b", re.IGNORECASE), + + # AA&P Students Only (accept AA&P or AAP; allow any junk in-between; optional hyphen) + "AA&P Students Only": re.compile( + r"\b(?:aa\s*&\s*p|aap)\b.*\bstudent[s]?[-\s]*only\b", + re.IGNORECASE + ), + + # Landscape Architecture Students Only (allow arbitrary whitespace; optional hyphen) + "Landscape Architecture Students Only": re.compile( + r"\blandscape\s+architecture\b.*\bstudent[s]?[-\s]*only\b", + re.IGNORECASE + ), + + # --- Printer capabilities --- + "Color, Scan, & Copy": re.compile( + r"\bcolor\s*[,/&]?\s*(?:scan\s*[,/&]?\s*)?(?:and\s*)?\s*&?\s*(?:copy|print|copying)\b", re.IGNORECASE + ), + "Black & White": re.compile( + r"\b(?:black\s*(?:and|&)\s*white|b\s*&\s*w)\b", re.IGNORECASE + ), + "Color": re.compile(r"\bcolor\b", re.IGNORECASE), + +} + +# Used for stripping residual trailing labels from descriptions +RESIDUAL_TRAILING_LABEL_RE = re.compile( + r"\b(?:resident|residents|student|students|staff|public)\b\s*$", + re.IGNORECASE +) + +def _norm(s): + """ + Unicode/HTML/whitespace normalization. + """ + if s is None: + return "" + s = unicodedata.normalize('NFKC', s) # Normalizes unicode text + s = HTML_TAG_RE.sub(" ", s) + s = s.replace("*", " ") + s = BRACKET_CONTENT_RE.sub(" ", s) + s = MULTI_SPACE_RE.sub(" ", s).strip() + return s + +def _strip_trailing_allcaps(s): + """ + Remove trailing ALL-CAPS qualifiers (e.g., RESIDENTS ONLY). + """ + return TRAILING_CAPS_RE.sub("", s).strip() + +def _pre_clean_for_match(s: str) -> str: + """ + Pre-clean a building name for matching against the canonical list. + """ + s = _norm(s) + s = LABEL_PHRASES_RE.sub(" ", s) # <— removes "Resident(s) only", "AA&P", etc. + s = _strip_trailing_allcaps(s) + s = RESIDUAL_TRAILING_LABEL_RE.sub(" ", s) # <— removes "Resident", "Students", etc. + + s = re.sub(r"[^\w\s\-’']", " ", s) # punctuation noise + s = re.sub(r"\s+", " ", s).strip() + return s + +def _token_sort(s): + """ + Tokenize a string, sort the tokens, and re-join them. + """ + tokens = s.lower().split() + tokens.sort() + return " ".join(tokens) + +def map_building(name, threshold=87): + """ + Map a building name to a canonical building name using fuzzy matching. + """ + if not name: + return None, 0 + + query = _token_sort(_pre_clean_for_match(name)) + canon_token_list = [_token_sort(_pre_clean_for_match(c)) for c in CANONICAL_BUILDINGS] + + # Returns a list of the (top-1) closest match to the cleaned name + best = get_close_matches(query, canon_token_list, n=1) + + # If no matches (empty list), return the original name and 0 + if not best: + return name, 0 - # Locate the table - table = soup.find("table", {"id": "directoryTable"}) - rows = table.find("tbody").find_all("tr") + # Return the closest match and its similarity score + match = best[0] - # Extract data + # Calculate the similarity score of the match to the original name (for internal use, potential debugging purposes) + index = canon_token_list.index(match) + canon_raw = CANONICAL_BUILDINGS[index] + score = int(SequenceMatcher(None, query, match).ratio() * 100) + + # If the score is below the threshold, return the original name instead of the canonical name + return (canon_raw, score) if score >= threshold else (name, score) + +def map_labels(text): + """ + Extract label tokens from the description. + """ + if not text: + return text, [] + + cleaned = _norm(text) + found_labels = [] + + for canon, pattern in LABEL_PATTERNS.items(): + # Search for the pattern in the cleaned text + if pattern.search(cleaned): + found_labels.append(canon) + cleaned = pattern.sub("", cleaned, count=1).strip() + + # Collapse runs of punctuation-delimiters to a single space + cleaned = re.sub(r"\s*[,;/|&\-–—:]+\s*", " ", cleaned) + + # Remove any leftover leading delimiters/spaces (e.g., ", ", "- ") + cleaned = re.sub(r"^[\s,;/|&\-–—:]+", "", cleaned) + + # Remove standalone "Copy", "Print", or "Scan" at the start (leftover from partial label removal) + cleaned = re.sub(r"^(?:copy|print|scan)\s+", "", cleaned, flags=re.IGNORECASE) + + # Final whitespace cleanup + cleaned = re.sub(r"\s+", " ", cleaned).strip() + + return cleaned, sorted(set(found_labels)) + +def fetch_printers_json(): + """ + Fetch printer data in JSON format from the CU Print directory endpoint. + """ + resp = requests.get(URL, headers=HEADERS, timeout=20) + resp.raise_for_status() + return resp.json() + +def scrape_printers(): + """ + Scrape CU Print printer locations from the Cornell directory page. + """ + payload = fetch_printers_json() data = [] - for row in rows: - cols = row.find_all("td") - if len(cols) < 3: # Ensure row has enough columns + + # payload['rows'] is a list of lists, where each inner list represents a row of data + for row in payload['rows']: + if len(row) < 3: # Ensure row has enough columns + continue # Skipping row with insufficient columns + + # Each row is of the structure ["Building", "Equipment & Location", "Coordinates (Lat, Lng)"] + [raw_building, raw_location, raw_coordinates] = row + + # Map raw building name to canonical building name + building, _ = map_building(raw_building) + + # If we weren't able to map the building to a canonical building, skip this row + # NOTE: This should prevent us from getting "None" as the location, which was happening earlier + if building not in CANONICAL_BUILDINGS: continue + + # Map labels from description to canonical labels + labels = [] - location_name = cols[0].text.strip() - description = cols[1].text.strip() + _, building_labels = map_labels(raw_building) # Get labels from the building name (e.g., "Residents Only") + remainder, location_labels = map_labels(raw_location) # Get labels from the location description (e.g., "Landscape Architecture Student ONLY") + + # Deduplicate and sort labels + labels += building_labels + labels += location_labels + labels = sorted(set(labels)) - # Extract coordinates from the hyperlink tag inside - coordinates_link = cols[2].find("a") - coordinates_string = coordinates_link.text.strip() if coordinates_link else "" - coordinates = [float(x) for x in coordinates_string.split(', ')] + cleaned = re.sub(r"^[\s\-–—:/|]+", "", remainder).strip() # Remove leftover delimiters at the start (like " - ", " / ", ": ", etc.) + description = cleaned # Final cleaned description text (with labels removed) — essentially, remainder of the location description + # Splits coordinates string into a list of floats + coordinates = [float(x) for x in raw_coordinates.split(', ')] data.append({ - "Location": location_name, + "Location": building, "Description": description, - "Coordinates": coordinates + "Coordinates": coordinates, + "Labels": labels }) - return data \ No newline at end of file + + return data + +if __name__ == "__main__": + results = scrape_printers() + print(f"Scraped {len(results)} printers.\n") + + # Print a sample of the data + for row in results: + if row['Location'] == 'Vet Library': + print(row['Description'], row['Labels']) \ No newline at end of file diff --git a/src/data/scripts/populate_db.py b/src/data/scripts/populate_db.py index fa6a23f4..30ddc62f 100644 --- a/src/data/scripts/populate_db.py +++ b/src/data/scripts/populate_db.py @@ -18,7 +18,7 @@ def populate_db(): # Insert printers printers = scrape_printers() for printer in printers: - insert_printer(printer['Location'], printer['Description'], printer['Coordinates'][0], printer['Coordinates'][1]) + insert_printer(printer['Location'], printer['Description'], printer['Labels'], printer['Coordinates'][0], printer['Coordinates'][1]) if __name__ == "__main__": populate_db() \ No newline at end of file diff --git a/src/data/scripts/run-migrations.js b/src/data/scripts/run-migrations.js new file mode 100644 index 00000000..428fbdee --- /dev/null +++ b/src/data/scripts/run-migrations.js @@ -0,0 +1,111 @@ +// Imports necessary for data migrations +import fs from 'fs' // Node's built-in file system module, which lets us read from disk +import path from 'path'; // Safer way to express file paths/path joining +import crypto from 'crypto'; +import Database from 'better-sqlite3'; +import { fileURLToPath } from 'url'; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +const DB_PATH = process.env.DB_PATH || path.join(__dirname, "../transit.db"); // Finds db file from current file's directory +const MIGRATIONS_DIR = path.join(__dirname, "../migrations"); + +/** + * Hashes a string using SHA-256 + * + * We use this to store the checksum of the migration file in the database. + * This allows us to track which migrations have been applied, as well as if a migration file has been modified since it was last applied. + * + * @param {string} s - The string to hash + * @returns {string} - The SHA-256 hash of the string + */ +function sha256(s) { + return crypto.createHash('sha256').update(s, 'utf8').digest('hex'); +} + +/** + * Runs the migrations + * + * This function reads all the migration files in the migrations directory, hashes them, and stores the checksum in the database. + * It then executes the migrations in the order of the files. + * + * @returns {void} + * @throws {Error} - If the migrations fail + */ +function runMigration() { + // Open the database using the better-sqlite3 library + const db = new Database(DB_PATH); + + // Set defaults for migrations + db.pragma('journal_mode = WAL'); + db.pragma('synchronous = NORMAL'); + db.pragma('foreign_keys = ON'); + + // Create the schema_migrations table if it doesn't exist for tracking migrations applied to the database + db.exec(` + CREATE TABLE IF NOT EXISTS schema_migrations ( + id INTEGER PRIMARY KEY, + filename TEXT NOT NULL UNIQUE, + checksum TEXT NOT NULL, + applied_at TEXT NOT NULL DEFAULT (datetime('now')) + ) + `); + + // Get the list of migrations that have already been applied to the database + const applied = new Set( + db.prepare('SELECT filename FROM schema_migrations').all().map(record => record.filename) + ); + + // Get the list of migration files in the migrations directory (keeping only .sql files and sorting them chronologically) + const files = fs.readdirSync(MIGRATIONS_DIR).filter(f => f.endsWith('.sql')).sort(); + + // Prepare the statement to insert a new migration into the schema_migrations table + const insertMig = db.prepare(` + INSERT INTO schema_migrations (filename, checksum) VALUES (?,?) + `); + + // Define a transaction to execute the migrations + const transaction = db.transaction(() => { + for (const file of files) { + // Skip if the migration has already been applied + if (applied.has(file)) { + continue; + } + + const full = path.join(MIGRATIONS_DIR, file); + const sql = fs.readFileSync(full, 'utf8').trim(); + if (!sql) { + continue; + } + + // Defensive: re-enable FKs inside each run (is already done in the migrations, but just in case) + db.exec('PRAGMA foreign_keys = ON;'); + + // Execute SQL commands in the migration file + db.exec(sql); + + // Records migration as applied to the database via its check + insertMig.run(file, sha256(sql)); + console.log(`Applied ${file}`); + } + }); + + try { + transaction(); + console.log('All migrations applied'); + } catch (e) { + console.error("Migration failed", e); + } finally { + db.close(); + } +} + +export function runMigrations() { + runMigration(); +} + +import { pathToFileURL } from 'url'; +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + runMigrations(); + } \ No newline at end of file diff --git a/src/data/scripts/socket_test_client.js b/src/data/scripts/socket_test_client.js new file mode 100644 index 00000000..06908a7b --- /dev/null +++ b/src/data/scripts/socket_test_client.js @@ -0,0 +1,32 @@ +import { io } from 'socket.io-client'; + +const URL = "http://localhost:3000"; +const role = process.argv[2] ?? "user"; +const netid = process.argv[3] ?? "ce123"; + +// Create a client to connect to the server +const socket = io(URL, { // io is a factory function that creates a socket instance + transports: ['websocket'], // Specifies the transport to use for the socket connection +}); + +socket.on('connect', () => { + console.log('Connected to server'); + socket.emit('identify', { role, netid }); +}); + +socket.on("identify:error", (payload) => { + console.log("identify:error", payload); + }); + +socket.on("eventForm:new", (payload) => { + console.log("eventForm:new", payload); +}); + +socket.on("eventForm:update", (payload) => { + console.log("eventForm:update", payload); +}); + +socket.on("disconnect", (reason) => { + console.log("disconnected", reason); +}); + diff --git a/src/data/transit.db b/src/data/transit.db index 0ebc41a4..6ad3c8af 100644 Binary files a/src/data/transit.db and b/src/data/transit.db differ diff --git a/src/index.js b/src/index.js index efef3dc0..bb8fdadb 100644 --- a/src/index.js +++ b/src/index.js @@ -10,6 +10,7 @@ import notifRoutes from "./controllers/NotificationController.js"; import reportingRoutes from "./controllers/RouteReportingController.js"; import stopsRoutes from "./controllers/StopsController.js"; import ecosystemRoutes from "./controllers/EcosystemController.js"; +import eventFormsRoutes from "./controllers/EventFormsController.js"; import NotificationUtils from "./utils/NotificationUtils.js"; import RealtimeFeedUtilsV3 from "./utils/RealtimeFeedUtilsV3.js"; @@ -21,10 +22,26 @@ import AlertsUtils from "./utils/AlertsUtils.js"; import AllStopUtils from "./utils/AllStopUtils.js"; import GTFSUtils from "./utils/GTFSUtils.js"; +import { createServer } from "http"; +import { Server as SocketIOServer } from "socket.io"; const app = express(); const port = process.env.PORT; +const httpServer = createServer(app); + +// Setup Socket.IO +const io = new SocketIOServer(httpServer, { + cors: { + origin: `http://localhost:${port}`, // Come back to, update URL + methods: ["GET", "POST"], + credentials: true, + }, +}); + +// Set the io instance to the app, so it can be accessed by the controllers +app.set("io", io); + app.use(express.json()); app.use('/api/v1/', delayRoutes); @@ -43,6 +60,8 @@ app.use('/api/v1/', reportingRoutes); app.use('/api/v1/', ecosystemRoutes); +app.use('/api/v1/', eventFormsRoutes); + // Setup Swagger docs app.use("/api-docs", swaggerUi.serve, swaggerUi.setup(swaggerDoc)); @@ -64,8 +83,55 @@ admin.initializeApp({ databaseURL: "https://ithaca-transit.firebaseio.com", }); +const ALLOWED_ROLES = ['admin', 'user']; + +// Handle a given socket's connections +io.on("connection", (socket) => { + // Log the socket connection + console.log("Client connected: ", socket.id); + + // Identify the socket's + socket.on("identify", ({role, netid}) => { + + // Safety checks - make sure the netid and role are valid + if (!netid || !role) { + console.error("Invalid netid or role - netid and role are required"); + socket.emit("identify:error", {message: "Invalid netid or role - netid and role are required"}); + return; + }; + + // Ensures role is valid + if (!ALLOWED_ROLES.includes(role)) { + console.error("Invalid role - role must be one of: " + ALLOWED_ROLES.join(", ")); + socket.emit("identify:error", {message: "Invalid role - role must be one of: " + ALLOWED_ROLES.join(", ")}); + return; + }; + + // Makes the socket a member of the public room — done only after all other checks pass + socket.join("public"); + + // Set the socket's data + socket.data.netid = netid; + socket.data.role = role; + console.log("Socket identified: ", socket.data); + + if (role === 'admin') { + socket.join("admin"); + } + + if (role === 'user') { + socket.join(`netid:${netid}`); + } + }); + + // Log the socket disconnection + socket.on("disconnect", () => { + console.log("Client disconnected: ", socket.id); + }); +}); + // Start the server -app.listen(port, () => { +httpServer.listen(port, () => { console.log(`[server]: Server is running at http://localhost:${port}`); console.log(`Swagger docs available at http://localhost:${port}/api-docs`); }); diff --git a/src/swagger.json b/src/swagger.json index ff9b0afe..a4ef711b 100644 --- a/src/swagger.json +++ b/src/swagger.json @@ -66,7 +66,7 @@ ], "responses": { "200": { - "description": "{\"success\": true, \"data\": [{\"id\": 1, \"location\": \"Akwe:kon\", \"description\": \"Color - Room 115\", \"latitude\": 42.4563, \"longitude\": -76.4806}]}", + "description": "{\"success\": true, \"data\": [{\"id\": 1, \"location\": \"Akwe:kon\", \"description\": \"Room 115\", \"latitude\": 42.4563, \"longitude\": -76.4806, \"labels\": [\"Color\"]}]}", "schema": { "$ref": "#/components/schemas/BusStop" } @@ -74,6 +74,278 @@ } } }, + "/api/v1/events/": { + "get": { + "summary": "Returns an object containing a list of all event requests.", + "description": "Returns an object with fields success, message, and data, where data is a list of all event requests.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Returns an object with fields success, message, and data.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "All event requests retrieved successfully" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/EventRequest" + } + } + } + } + } + } + }, + "400": { + "description": "Returns an object with fields success, message, and error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Error getting all event requests" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/v1/events/create-event": { + "post": { + "summary": "Submits an event request.", + "description": "Takes in a list of objects with fields netid, name, eventType, startDate, endDate, organizationName, location, and about and returns a list of objects with fields success, message, and data.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "description": "The event request to submit with fields netid, name, eventType, startDate, endDate, organizationName, location, and about.", + "required": true, + "schema": { + "$ref": "#/components/schemas/EventRequest" + } + } + ], + "responses": { + "201": { + "description": "Returns an object with fields success, message, and data.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Event request submitted successfully" + }, + "data": { + "$ref": "#/components/schemas/EventRequest" + } + } + } + } + } + }, + "400": { + "description": "Returns an object with fields success, message, and error. If the error is due to an invalid event form, the error message will be 'Invalid event form — netid, name, event type, and location are required'.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Error submitting event request" + }, + "error": { + "type": "string", + "example": "Invalid event form — netid, name, event type, and location are required" + } + } + } + } + } + } + } + } + }, + "/api/v1/events/:id": { + "put": { + "summary": "Updates an event request.", + "description": "Takes in a list of objects with fields id and approvalStatus and returns a list of objects with fields success, message, and data.", + "produces": [ + "application/json" + ], + "parameters": [ + { + "in": "path", + "name": "id", + "description": "The id of the event request to update, as specified in the database.", + "required": true, + "schema": { + "type": "string" + } + }, + { + "in": "body", + "name": "approvalStatus", + "description": "The approval status to update the event request to. Allowed approval statuses are: 'pending', 'approved', 'rejected'.", + "required": true, + "schema": { + "type": "string", + "enum": [ + "pending", + "approved", + "rejected" + ], + "example": "approved" + } + } + ], + "responses": { + "200": { + "description": "Returns an object with fields success, message, and data.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "Event request updated successfully" + }, + "data": { + "$ref": "#/components/schemas/PrivateEventRequest" + } + } + } + } + } + }, + "400": { + "description": "Returns an object with fields success, message, and error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Error updating event request" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, + "/api/v1/events/approved": { + "get": { + "summary": "Returns an object containing a list of all approved event requests.", + "description": "Returns an object with fields success, message, and data, where data is a list of all approved event requests.", + "produces": [ + "application/json" + ], + "responses": { + "200": { + "description": "Returns an object with fields success, message, and data.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": true + }, + "message": { + "type": "string", + "example": "All approved event requests retrieved successfully" + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/PublicEventRequest" + } + } + } + } + } + } + }, + "400": { + "description": "Returns an object with fields success, message, and error.", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "example": false + }, + "message": { + "type": "string", + "example": "Error getting all approved event requests" + }, + "error": { + "type": "string" + } + } + } + } + } + } + } + } + }, "/api/v1/delays": { "post": { "summary": "Returns a list of bus delays for buses at a specific stop. **Recommended and most up-to-date.", @@ -1332,6 +1604,116 @@ "example": 34 } } + }, + "PublicEventRequest": { + "type": "object", + "properties": { + "netid": { + "type": "string", + "description": "The netid of the user submitting the event request.", + "example": "jdoe123" + }, + "name": { + "type": "string", + "description": "The name of the event.", + "example": "Event Name" + }, + "eventType": { + "type": "string", + "description": "The type of event.", + "example": "temporary" + }, + "startDate": { + "type": "string", + "description": "The start date of the event.", + "example": "2025-01-01 12:00:00" + }, + "endDate": { + "type": "string", + "description": "The end date of the event.", + "example": "2025-01-02 12:00:00" + }, + "organizationName": { + "type": "string", + "description": "The name of the organization hosting the event.", + "example": "Organization Name" + }, + "location": { + "type": "string", + "description": "The location of the event.", + "example": "Location Name" + }, + "about": { + "type": "string", + "description": "The about section of the event.", + "example": "About the event." + } + } + }, + "PrivateEventRequest": { + "type": "object", + "properties": { + "id": { + "type": "integer", + "description": "The id of the event request.", + "example": 1 + }, + "netid": { + "type": "string", + "description": "The netid of the user submitting the event request.", + "example": "jdoe123" + }, + "name": { + "type": "string", + "description": "The name of the event.", + "example": "Event Name" + }, + "eventType": { + "type": "string", + "description": "The type of event.", + "example": "temporary" + }, + "startDate": { + "type": "string", + "description": "The start date of the event.", + "example": "2025-01-01 12:00:00" + }, + "endDate": { + "type": "string", + "description": "The end date of the event.", + "example": "2025-01-02 12:00:00" + }, + "organizationName": { + "type": "string", + "description": "The name of the organization hosting the event.", + "example": "Organization Name" + }, + "location": { + "type": "string", + "description": "The location of the event.", + "example": "Location Name" + }, + "about": { + "type": "string", + "description": "The about section of the event.", + "example": "About the event." + }, + "approvalStatus": { + "type": "string", + "description": "The approval status of the event request.", + "example": "pending" + }, + "createdAt": { + "type": "string", + "description": "The date and time the event request was created.", + "example": "2025-01-01 12:00:00" + }, + "updatedAt": { + "type": "string", + "description": "The date and time the event request was last updated.", + "example": "2025-01-01 12:00:00" + } + } } } }, diff --git a/src/utils/EcosystemUtils.js b/src/utils/EcosystemUtils.js index 5aadd2b8..a5e979ab 100644 --- a/src/utils/EcosystemUtils.js +++ b/src/utils/EcosystemUtils.js @@ -45,7 +45,7 @@ function fetchAllPrinters() { }); // Fetch printers - db.all("SELECT * FROM printers", (err, rows) => { + db.all("SELECT p.id, p.location, p.description, p.latitude, p.longitude, COALESCE(GROUP_CONCAT(DISTINCT l.label, ', '), '') AS labels FROM printers p LEFT JOIN printer_labels pl ON p.id = pl.printer_id LEFT JOIN labels l ON pl.label_id = l.id GROUP BY p.id", (err, rows) => { if (err) { console.error(err.message); return reject(err); diff --git a/src/utils/EventFormsUtils.js b/src/utils/EventFormsUtils.js new file mode 100644 index 00000000..68a362ae --- /dev/null +++ b/src/utils/EventFormsUtils.js @@ -0,0 +1,229 @@ +import sqlite3 from "sqlite3"; +import { fileURLToPath } from "url"; +import path from "path"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); +const dbPath = path.join(__dirname, "..", "data", "transit.db"); + +const ALLOWED_EVENT_TYPES = ['temporary', 'permanent']; +const ALLOWED_APPROVAL_STATUSES = ['pending', 'approved', 'rejected']; + +/** + * Creates an event form in the database. + * + * @param eventForm - The event form to create. + * @returns {Promise} - The created event form. + * @throws {Error} - If the event form is invalid. + * @throws {Error} - If the database connection fails. + */ + +function createEventForm({ netid, name, eventType, startDate = null, endDate = null, organizationName = null, location, about = null }) { + // Safety checks - make sure the event form is valid + if (!netid || !name || !eventType || !location) { + throw new Error("Invalid event form — netid, name, event type, and location are required"); + }; + + // Ensures event type is valid + if (!ALLOWED_EVENT_TYPES.includes(eventType)) { + throw new Error('Invalid event form — event type invalid'); + } + + // Handle event types + if (eventType == 'temporary') { + // If the event is temporary (e.g., tabling), then we require event's date(s) and times, and name of the hosting organization + if (!startDate || !endDate) { + // NOTE: The start and end dates are required for temporary events + throw new Error("Invalid event form — start and end dates are required for temporary events"); + } + if (!organizationName) { + // NOTE: The organization name is required for temporary events + throw new Error("Invalid event form — organization name is required for temporary events"); + } + } + + // Create the event form + const eventForm = { + netid, + name, + eventType, + startDate, + endDate, + organizationName, + location, + about, + approvalStatus: 'pending', + }; + + return new Promise((resolve, reject) => { + // Open the database + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error(err.message); + return reject(err); + } + }); + + // Prepare the query + const query = `INSERT INTO event_forms (name, netid, event_type, start_date, end_date, organization_name, location, approval_status, about) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)`; + const values = [eventForm.name, eventForm.netid, eventForm.eventType, eventForm.startDate, eventForm.endDate, eventForm.organizationName, eventForm.location, eventForm.approvalStatus, eventForm.about]; + + // Insert the event form into the database + db.run(query, values, function (err) { + if (err) { + db.close(); + console.error(err.message); + return reject(err); + } + + // Get the inserted event form + db.get(`SELECT * FROM event_forms WHERE id = ?`, [this.lastID], (err, row) => { + db.close(); + if (err) { + console.error(err.message); + return reject(err); + } + return resolve(row); + }); + }); + }); +} + +/** + * Gets all event forms from the database. + * + * @returns {Promise>} - The event forms. + * @throws {Error} - If the database connection fails. + */ +function getAllEventForms() { + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error(err.message); + return reject(err); + } + }); + + // Prepare the query + const query = `SELECT * FROM event_forms`; + db.all(query, (err, rows) => { + db.close(); + if (err) { + console.error(err.message); + return reject(err); + } + return resolve(rows); + }); + }); +} + +/** + * Updates the approval status of a specified event form in the database. + * + * Allowed approval statuses are: 'pending', 'approved', 'rejected'. + * + * @param integer id - The id of the event form to update. + * @param {Object} approvalStatus - The approval status to update the event form to. + * @returns {Promise} - The updated event form. + * @throws {Error} - If the event form is invalid or the approval status is invalid. + * @throws {Error} - If the event form is not found. + */ +function updateEventForm({ id, approvalStatus }) { + // Safety checks - make sure the event form is valid + if (!id || !approvalStatus) throw new Error("Invalid event form — id and approval status are required"); + // Ensures approval status is valid + if (!ALLOWED_APPROVAL_STATUSES.includes(approvalStatus)) { + throw new Error('Invalid event form — approval status invalid'); + } + + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error(err.message); + return reject(err); + } + }); + + // Prepare the query + const query = `UPDATE event_forms SET approval_status = ? WHERE id = ?`; + const values = [approvalStatus, id]; + + // Update the event form in the database + db.run(query, values, function (err) { + if (err) { + db.close(); + console.error(err.message); + return reject(err); + } + + // Checks if there were no updates to the event form (in which case, there was an error) + if (this.changes === 0) { + db.close(); + return reject(new Error("Event form not found")); + } + + // Get the updated event form + db.get(`SELECT * FROM event_forms WHERE id = ?`, [id], (err, row) => { + db.close(); + if (err) { + console.error(err.message); + return reject(err); + } + return resolve(row); + }); + }); + }); +} + +/** + * Gets all approved event forms from the database. + * + * @returns {Promise>} - The approved event forms. + * @throws {Error} - If the database connection fails. + */ +function getApprovedEventForms() { + return new Promise((resolve, reject) => { + const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error(err.message); + return reject(err); + } + }); + + // Prepare the query + const query = `SELECT * FROM event_forms WHERE approval_status = 'approved'`; + db.all(query, (err, rows) => { + if (err) { + console.error(err.message); + return reject(err); + } + resolve(rows); + }); + + // Close the database + db.close((err) => { + if (err) console.error(err.message); + }); + }); +} + +/** + * Converts an event form to a public event. + * + * @param {Object} eventForm - The event form to convert. + * @returns {Object} - The public event. + */ +function toPublicEvent({ name, netid, eventType, startDate, endDate, organizationName, about, location }) { + return { + name, + netid, + eventType, + startDate, + endDate, + organizationName, + about, + location, + } +} + +export { createEventForm, getAllEventForms, updateEventForm, getApprovedEventForms, toPublicEvent }; \ No newline at end of file