diff --git a/package-lock.json b/package-lock.json index 7ddc88a..d78d4cc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,6 +10,7 @@ "license": "ISC", "dependencies": { "@babel/cli": "^7.20.7", + "@sendgrid/mail": "^8.1.1", "cors": "^2.8.5", "coveralls": "^3.1.1", "cron": "^2.3.0", @@ -26,6 +27,7 @@ "pg": "^8.9.0", "pg-hstore": "^2.3.4", "sequelize": "^6.29.0", + "socket.io": "^4.7.5", "source-map": "^0.7.4", "swagger-ui-express": "^4.6.1", "valibot": "^0.30.0" @@ -2208,6 +2210,41 @@ "node": ">=14" } }, + "node_modules/@sendgrid/client": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@sendgrid/client/-/client-8.1.1.tgz", + "integrity": "sha512-pg0gYhAdyQil3Aga7/xHVcZFpvDAjAQMNBgMy5njTSkjACoWHmpSi1nWBZM7nIH/ptcRNMpnBbm9B5EvQ8fX2w==", + "dependencies": { + "@sendgrid/helpers": "^8.0.0", + "axios": "^1.6.4" + }, + "engines": { + "node": ">=12.*" + } + }, + "node_modules/@sendgrid/helpers": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sendgrid/helpers/-/helpers-8.0.0.tgz", + "integrity": "sha512-Ze7WuW2Xzy5GT5WRx+yEv89fsg/pgy3T1E3FS0QEx0/VvRmigMZ5qyVGhJz4SxomegDkzXv/i0aFPpHKN8qdAA==", + "dependencies": { + "deepmerge": "^4.2.2" + }, + "engines": { + "node": ">= 12.0.0" + } + }, + "node_modules/@sendgrid/mail": { + "version": "8.1.1", + "resolved": "https://registry.npmjs.org/@sendgrid/mail/-/mail-8.1.1.tgz", + "integrity": "sha512-tNtmgWLtBA7ZxKtPuEGOaIdEZP1vZSXsj5zg9iuoDBPVj/fNz+7LWzndvTcKumHk5eaDrS0UPXJqBm61m3+H1A==", + "dependencies": { + "@sendgrid/client": "^8.1.1", + "@sendgrid/helpers": "^8.0.0" + }, + "engines": { + "node": ">=12.*" + } + }, "node_modules/@sinonjs/commons": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.1.tgz", @@ -2252,18 +2289,36 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.0.tgz", + "integrity": "sha512-+9jVqKhRSpsc591z5vX+X5Yyw+he/HCB4iQ/RYxw35CEPaY1gnsNE43nf9n9AaYjAQrTiI/mOwKUKdUs9vf7Xg==" + }, "node_modules/@types/chai": { "version": "4.3.12", "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.12.tgz", "integrity": "sha512-zNKDHG/1yxm8Il6uCCVsm+dRdEsJlFoDu73X17y09bId6UwoYww+vFBsAcRzl8knM1sab3Dp1VRikFQwDOtDDw==", "dev": true }, + "node_modules/@types/cookie": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.4.1.tgz", + "integrity": "sha512-XW/Aa8APYr6jSVVA1y/DEIZX0/GMKLEVekNG727R8cs56ahETkRAy/3DR7+fJyh7oUgGwNQaRfXCun0+KbWY7Q==" + }, "node_modules/@types/cookiejar": { "version": "2.1.5", "resolved": "https://registry.npmjs.org/@types/cookiejar/-/cookiejar-2.1.5.tgz", "integrity": "sha512-he+DHOWReW0nghN24E1WUqM0efK4kI9oTqDm6XmK8ZPe2djZ90BSNdGnIyCLzCPw7/pogPlGbzI2wHGGmi4O/Q==", "dev": true }, + "node_modules/@types/cors": { + "version": "2.8.17", + "resolved": "https://registry.npmjs.org/@types/cors/-/cors-2.8.17.tgz", + "integrity": "sha512-8CGDvrBj1zgo2qE+oS3pOCyYNqCPryMWY2bGfwA0dcfopWGgxs+78df0Rs3rc9THP4JkOhLsAa+15VdpAqkcUA==", + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/debug": { "version": "4.1.12", "resolved": "https://registry.npmjs.org/@types/debug/-/debug-4.1.12.tgz", @@ -2577,6 +2632,29 @@ "resolved": "https://registry.npmjs.org/aws4/-/aws4-1.12.0.tgz", "integrity": "sha512-NmWvPnx0F1SfrQbYwOi7OeaNGokp9XhzNioJ/CSBs8Qa4vxug81mhJEAVZwxXuBmYB5KDRfMq/F3RR0BIU7sWg==" }, + "node_modules/axios": { + "version": "1.6.8", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.6.8.tgz", + "integrity": "sha512-v/ZHtJDU39mDpyBoFVkETcd/uNdxrWRrg3bKpOKzXFA6Bvqopts6ALSMU3y6ijYxbw2B+wPrIv46egTzJXCLGQ==", + "dependencies": { + "follow-redirects": "^1.15.6", + "form-data": "^4.0.0", + "proxy-from-env": "^1.1.0" + } + }, + "node_modules/axios/node_modules/form-data": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", + "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "dependencies": { + "asynckit": "^0.4.0", + "combined-stream": "^1.0.8", + "mime-types": "^2.1.12" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/babel-plugin-polyfill-corejs2": { "version": "0.4.8", "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs2/-/babel-plugin-polyfill-corejs2-0.4.8.tgz", @@ -2621,6 +2699,14 @@ "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" }, + "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==", + "engines": { + "node": "^4.5.0 || >= 5.9" + } + }, "node_modules/base64url": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/base64url/-/base64url-3.0.1.tgz", @@ -3296,6 +3382,14 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/deepmerge": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/deepmerge/-/deepmerge-4.3.1.tgz", + "integrity": "sha512-3sUqbMEc77XqpdNO7FRyRog+eW3ph+GYCbj+rK+uYyRMuwsVy0rMiVtPn+QJlKFvWP/1PYpapqYn0Me2knFn+A==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/default-require-extensions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/default-require-extensions/-/default-require-extensions-3.0.1.tgz", @@ -3559,6 +3653,42 @@ "node": ">= 0.8" } }, + "node_modules/engine.io": { + "version": "6.5.4", + "resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.5.4.tgz", + "integrity": "sha512-KdVSDKhVKyOi+r5uEabrDLZw2qXStVvCsEB/LN3mw4WFi6Gx50jTyuxYVCwAAC0U46FdnzP/ScKRBTXb/NiEOg==", + "dependencies": { + "@types/cookie": "^0.4.1", + "@types/cors": "^2.8.12", + "@types/node": ">=10.0.0", + "accepts": "~1.3.4", + "base64id": "2.0.0", + "cookie": "~0.4.1", + "cors": "~2.8.5", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.11.0" + }, + "engines": { + "node": ">=10.2.0" + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.2", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.2.tgz", + "integrity": "sha512-RcyUFKA93/CXH20l4SoVvzZfrSDMOTUS3bWVpTt2FuFP+XYrL8i8oonHP7WInRyVHXh0n/ORtoeiE1os+8qkSw==", + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/engine.io/node_modules/cookie": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.4.2.tgz", + "integrity": "sha512-aSWTXFzaKWkvHO1Ny/s+ePFpvKsPnjc551iI41v3ny/ow6tBG5Vd+FuqGNhh1LxOmVzOlGUriIlOaokOvhaStA==", + "engines": { + "node": ">= 0.6" + } + }, "node_modules/es-abstract": { "version": "1.22.5", "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.5.tgz", @@ -4429,6 +4559,25 @@ "integrity": "sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==", "dev": true }, + "node_modules/follow-redirects": { + "version": "1.15.6", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", + "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, "node_modules/for-each": { "version": "0.3.3", "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.3.tgz", @@ -7551,6 +7700,11 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-1.1.0.tgz", + "integrity": "sha512-D+zkORCbA9f1tdWRK0RaCR3GPv50cMxcrz4X8k5LTSUD1Dkw47mKJEZQNunItRTkWwgtaUSo1RVFRIG9ZXiFYg==" + }, "node_modules/psl": { "version": "1.9.0", "resolved": "https://registry.npmjs.org/psl/-/psl-1.9.0.tgz", @@ -8490,6 +8644,44 @@ "node": ">=6" } }, + "node_modules/socket.io": { + "version": "4.7.5", + "resolved": "https://registry.npmjs.org/socket.io/-/socket.io-4.7.5.tgz", + "integrity": "sha512-DmeAkF6cwM9jSfmp6Dr/5/mfMwb5Z5qRrSXLpo3Fq5SqyU8CMF15jIN4ZhfSwu35ksM1qmHZDQ/DK5XTccSTvA==", + "dependencies": { + "accepts": "~1.3.4", + "base64id": "~2.0.0", + "cors": "~2.8.5", + "debug": "~4.3.2", + "engine.io": "~6.5.2", + "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.4", + "resolved": "https://registry.npmjs.org/socket.io-adapter/-/socket.io-adapter-2.5.4.tgz", + "integrity": "sha512-wDNHGXGewWAjQPt3pyeYBtpWSq9cLE5UW1ZUPL/2eGK9jtse/FpXib7epSTsz0Q0m+6sg6Y4KtcFTlah1bdOVg==", + "dependencies": { + "debug": "~4.3.4", + "ws": "~8.11.0" + } + }, + "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==", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/source-map": { "version": "0.7.4", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.4.tgz", @@ -9507,6 +9699,26 @@ "typedarray-to-buffer": "^3.1.5" } }, + "node_modules/ws": { + "version": "8.11.0", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.11.0.tgz", + "integrity": "sha512-HPG3wQd9sNQoT9xHyNCXoDUa+Xw/VevmY9FoHyQ+g+rrMn4j6FB4np7Z0OhdTgjx6MgQLK7jwSy1YecU1+4Asg==", + "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/xtend": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/xtend/-/xtend-4.0.2.tgz", diff --git a/package.json b/package.json index 53cb6b7..0f00b9b 100644 --- a/package.json +++ b/package.json @@ -34,6 +34,7 @@ "homepage": "https://github.com/denislohan/codehills-be#readme", "dependencies": { "@babel/cli": "^7.20.7", + "@sendgrid/mail": "^8.1.1", "cors": "^2.8.5", "coveralls": "^3.1.1", "cron": "^2.3.0", @@ -50,6 +51,7 @@ "pg": "^8.9.0", "pg-hstore": "^2.3.4", "sequelize": "^6.29.0", + "socket.io": "^4.7.5", "source-map": "^0.7.4", "swagger-ui-express": "^4.6.1", "valibot": "^0.30.0" diff --git a/src/app.js b/src/app.js index f0d6cc4..99a3039 100644 --- a/src/app.js +++ b/src/app.js @@ -1,5 +1,6 @@ /* eslint-disable no-undef */ import express from "express"; +import * as http from "http"; import session from "express-session"; import dotenv from "dotenv"; import cors from "cors"; @@ -8,12 +9,20 @@ import router from "./restful/routes/index"; import fileUploader from "express-fileupload"; import { associate } from "./database/relationships"; import { passport } from "./restful/routes/authRouters"; +import { + listenToUserProjectAssigned, + listenToLeadProjectAssigned, +} from "./system/utils/listenToEvent"; +import { SocketUtil } from "./system/utils/SocketUtil"; import cronJob from "./system/utils/cronjob"; + dotenv.config(); const PORT = process.env.PORT || 4000; const app = express(); +const server = http.createServer(app); +SocketUtil.config(server); cronJob(); app.use(express.urlencoded({ extended: false })); app.use(cors()); @@ -47,7 +56,7 @@ const initializeDatabase = async () => { const start = () => { try { initializeDatabase(); - app.listen({ port: PORT }, () => + server.listen({ port: PORT }, () => process.stdout.write(`http://localhost:${PORT} \n`) ); } catch (error) { diff --git a/src/database/relationships/index.js b/src/database/relationships/index.js index 0afad71..dc2a673 100644 --- a/src/database/relationships/index.js +++ b/src/database/relationships/index.js @@ -102,6 +102,10 @@ export const associate = () => { DB.FieldReview.belongsTo(DB.RatingField, { foreignKey: "ratingFieldId", as: "ratingField", + }); + DB.Notification.belongsTo(DB.User, { + foreignKey: "userId", + as: "user", onDelete: "CASCADE", }); }; diff --git a/src/documentation/index.js b/src/documentation/index.js index b7da801..8bce869 100644 --- a/src/documentation/index.js +++ b/src/documentation/index.js @@ -9,6 +9,7 @@ import search from "./search"; import dashboard from "./auth/dashboard"; import ratingCategories from "./ratingCategory"; import ratingFields from "./ratingField"; +import notification from "./notification"; const defaults = swaggerDoc.paths; @@ -30,6 +31,7 @@ const paths = { ...search, ...ratingCategories, ...ratingFields, + ...notification, }; const config = { diff --git a/src/documentation/notification/index.js b/src/documentation/notification/index.js new file mode 100644 index 0000000..c3446e4 --- /dev/null +++ b/src/documentation/notification/index.js @@ -0,0 +1,56 @@ +import responses from "../responses"; + +const notification = { + "/notification": { + get: { + tags: ["Notifications"], + security: [{ JWT: [] }], + summary: "get notifications", + parameters: [ + { + in: "query", + name: "page", + required: false, + schema: { + example: "", + }, + }, + { + in: "query", + name: "limit", + required: false, + description: "limit can be any positive number greater than 0", + schema: { + example: "", + }, + }, + ], + consumes: ["application/json"], + responses, + }, + }, + + "/notification/{notificationId}": { + patch: { + tags: ["Notifications"], + security: [{ JWT: [] }], + summary: "Update User notifications", + parameters: [ + { + name: "notificationId", + in: "path", + description: "Id of the notification to be updated", + required: true, + schema: { + type: "string", + format: "uuid", + }, + }, + ], + consumes: ["application/json"], + responses, + }, + }, +}; + +export default notification; diff --git a/src/restful/controllers/NotificationController.js b/src/restful/controllers/NotificationController.js new file mode 100644 index 0000000..7601129 --- /dev/null +++ b/src/restful/controllers/NotificationController.js @@ -0,0 +1,40 @@ +import NotificationService from "../../services/notificationService"; + +export default class NotificationController { + static async getNotifications(req, res) { + try { + const { limit, page } = req.query; + const notifications = await NotificationService.getNotifications( + { userId: req.user.id }, + limit, + page + ); + return res + .status(200) + .json({ message: "Fetched all notifications", notifications }); + } catch (err) { + return res.status(500).json({ + error: err.message, + message: "Failed to fetch notifications", + }); + } + } + + static async markOneNotification(req, res) { + try { + await NotificationService.updateNotifications( + { read: true }, + { id: req.params.notificationId, userId: req.user.id } + ); + + return res + .status(200) + .json({ message: "Marked one notification as read" }); + } catch (err) { + return res.status(500).json({ + error: err.message, + message: "Failed to update the notification", + }); + } + } +} diff --git a/src/restful/controllers/projectController.js b/src/restful/controllers/projectController.js index 931a728..c5c8f68 100644 --- a/src/restful/controllers/projectController.js +++ b/src/restful/controllers/projectController.js @@ -1,8 +1,9 @@ import db from "../../database"; import sendEmail from "../../services/emailService"; -import notificationService from "../../services/notificationService"; +import NotificationService from "../../services/notificationService"; import projectService from "../../services/projectService"; import UserService from "../../services/userService"; +import { eventEmit, knownEvents } from "../../system/utils/event.util"; const { Project, UserProject } = db; const { @@ -16,7 +17,7 @@ const { } = projectService; const { findOneUser } = UserService; -const { createNotification } = notificationService; +const { createNotification } = NotificationService; export default class projectController { static async createProject(req, res) { @@ -120,22 +121,10 @@ export default class projectController { } await project.addUsers(user); - //send emails and notifications to assigned users - - const userNotification = { - title: "Added to the project!", - description: `You have been added to the ${project.name} project`, - url: `${process.env.FRONTEND_URL}/projects/${project.id}`, - userId: user.id, - }; - - await createNotification(userNotification); - sendEmail( - user.email, - "Added to the project", - `Hello ${user.firstName}, You have been added to the "${project.name}" project`, - `${process.env.FRONTEND_URL}/projects/${project.id}` - ); + eventEmit(knownEvents.addUserToProject, { + user, + project, + }); const adminNotification = { title: "Added to the project!", @@ -144,7 +133,7 @@ export default class projectController { userId: user.id, }; await createNotification(adminNotification); - await sendEmail( + sendEmail( admin.email, "Added a user to the project", `Hello admin, You have added ${user.firstName} to the ${project.name} project`, @@ -382,19 +371,10 @@ export default class projectController { await project.update({ projectLeadId: leadUser.id }); - const leadNotification = { - title: "Added to the project as lead!", - description: `You have been added to the project "${project.name}" as the project lead`, - url: `${process.env.FRONTEND_URL}/projects/${project.id}`, - userId: leadUser.id, - }; - await createNotification(leadNotification); - await sendEmail( - leadUser.email, - "Added to the project as lead!", - `Hello ${leadUser.displayName}, You have been added to the project "${project.name}" as the project lead.`, - `${process.env.FRONTEND_URL}/projects/${project.id}` - ); + eventEmit(knownEvents.addLeadToProject, { + leadUser, + project, + }); const adminNotification = { title: "Project lead updated", @@ -403,7 +383,7 @@ export default class projectController { userId: admin.id, }; await createNotification(adminNotification); - await sendEmail( + sendEmail( admin.email, "Project lead updated!", `Hello admin, You have added "${leadUser.displayName}" as the project lead for "${project.name}" project.`, diff --git a/src/restful/controllers/userControllers.js b/src/restful/controllers/userControllers.js index 378693a..1b794d8 100644 --- a/src/restful/controllers/userControllers.js +++ b/src/restful/controllers/userControllers.js @@ -2,10 +2,10 @@ import Response from "./../../system/helpers/Response"; import DB from "./../../database"; import UserService from "../../services/userService"; import { fileUploader } from "../../system/fileUploader"; -import notificationService from "../../services/notificationService"; +import NotificationService from "../../services/notificationService"; import sendEmail from "../../services/emailService"; const { User } = DB; -const { createNotification } = notificationService; +const { createNotification } = NotificationService; export default class UserControllers { static async addUser(req, res) { diff --git a/src/restful/middlewares/notificationMiddleware.js b/src/restful/middlewares/notificationMiddleware.js new file mode 100644 index 0000000..7476fbc --- /dev/null +++ b/src/restful/middlewares/notificationMiddleware.js @@ -0,0 +1,23 @@ +import NotificationService from "../../services/notificationService"; + +export const receivedPaginationFormat = async (req, res, next) => { + req.query = { + limit: req.query["limit"] || "10", + page: req.query["page"] || "1", + }; + next(); +}; + +export const checkIfHasNotificationId = async (req, res, next) => { + const notification = await NotificationService.getNotifications( + { + id: req.params.notificationId, + }, + null, + null + ); + if (notification.rows.length === 0) { + return res.status(404).json({ message: "Notification not found" }); + } + next(); +}; diff --git a/src/restful/middlewares/validateRequestBody.js b/src/restful/middlewares/validateRequestBody.js index aa0814f..a51273e 100644 --- a/src/restful/middlewares/validateRequestBody.js +++ b/src/restful/middlewares/validateRequestBody.js @@ -1,6 +1,6 @@ import * as v from "valibot"; -import { filterValidationError } from "../../system/utils"; import Response from "../../system/helpers/Response"; +import { filterValidationError } from "../../system/utils"; export function validateRequestBody(validationSchema) { return (req, res, next) => { @@ -23,3 +23,17 @@ export function validateRequestBody(validationSchema) { return next(); }; } + +export function validateQueryParams(validationSchema) { + return (req, res, next) => { + const results = v.safeParse(validationSchema, req.query); + if (!results.success) { + return Response.error(res, 400, { + message: "Please correct your inputs", + errors: filterValidationError(results.issues), + }); + } + req.query = results.output; + return next(); + }; +} diff --git a/src/restful/routes/index.js b/src/restful/routes/index.js index 606cc49..3550ab5 100644 --- a/src/restful/routes/index.js +++ b/src/restful/routes/index.js @@ -12,6 +12,7 @@ import searchRouter from "./searchRouter"; import dashboardRouter from "./dashboard"; import ratingCategoryRouter from "./ratingCategoryRoutes"; import ratingFieldRouter from "./ratingFieldRoutes"; +import notificationRouter from "./notificationRoutes"; const API_VERSION = process.env.API_VERSION || "v1"; const url = `/api/${API_VERSION}`; @@ -29,6 +30,7 @@ router.use(`${url}/search`, searchRouter); router.use(`${url}/dashboard`, dashboardRouter); router.use(`${url}/ratingCategories`, ratingCategoryRouter); router.use(`${url}/ratingFields`, ratingFieldRouter); +router.use(`${url}/notification`, notificationRouter); router.all(`${url}/`, (req, res) => { return res.status(200).json({ message: "Welcome to codehills backend!" }); diff --git a/src/restful/routes/notificationRoutes.js b/src/restful/routes/notificationRoutes.js new file mode 100644 index 0000000..d4dfa62 --- /dev/null +++ b/src/restful/routes/notificationRoutes.js @@ -0,0 +1,27 @@ +import express from "express"; +import { paginationSchema } from "../../system/validators"; +import NotificationController from "../controllers/NotificationController"; +import protect from "../middlewares"; +import { + checkIfHasNotificationId, + receivedPaginationFormat, +} from "../middlewares/notificationMiddleware"; +import { validateQueryParams } from "../middlewares/validateRequestBody"; + +const notificationRouter = express.Router(); + +notificationRouter.get( + "/", + protect, + validateQueryParams(paginationSchema), + receivedPaginationFormat, + NotificationController.getNotifications +); + +notificationRouter.patch( + "/:notificationId", + protect, + checkIfHasNotificationId, + NotificationController.markOneNotification +); +export default notificationRouter; diff --git a/src/services/emailService.js b/src/services/emailService.js index 0310fbb..f217b59 100644 --- a/src/services/emailService.js +++ b/src/services/emailService.js @@ -1,19 +1,10 @@ -const nodemailer = require("nodemailer"); +import sgMail from "@sendgrid/mail"; const ejs = require("ejs"); const path = require("path"); -const { EMAIL_SERVICE, EMAIL_HOST, EMAIL_PORT, SENDER_EMAIL, EMAIL_PASSWORD } = - process.env; -let transporter = nodemailer.createTransport({ - service: EMAIL_SERVICE, - host: EMAIL_HOST, - port: EMAIL_PORT, - secure: false, // true for 465, false for other ports - auth: { - user: SENDER_EMAIL, - pass: EMAIL_PASSWORD, - }, -}); +const { SEND_GRID_EMAIL, SENDGRID_API_KEY } = process.env; + +sgMail.setApiKey(SENDGRID_API_KEY); export default async function sendEmail(to, subject, body, url) { try { @@ -22,14 +13,26 @@ export default async function sendEmail(to, subject, body, url) { { subject, body, url } ); - let info = await transporter.sendMail({ - from: SENDER_EMAIL, + let message = { to: to, + from: { + name: "CODEHILLS HR APP", + email: SEND_GRID_EMAIL, + }, subject: subject, html: html, - }); + }; - console.log("Message sent: %s", info.messageId); + return sgMail + .send(message) + .then((res) => { + console.log("email sent..."); + return res; + }) + .catch((error) => { + console.error(error.message); + throw error; + }); } catch (error) { console.error("Error sending email:", error); throw Error(error); diff --git a/src/services/notificationService.js b/src/services/notificationService.js index 8fa2407..f4331d3 100644 --- a/src/services/notificationService.js +++ b/src/services/notificationService.js @@ -2,9 +2,9 @@ /* eslint-disable no-useless-catch */ /* eslint-disable no-unused-vars */ import db from "./../database"; -const { Notification } = db; +const { Notification, User } = db; -export default class notificationService { +export default class NotificationService { /** * Creates a new message. * @param {object} param details of a message. @@ -19,4 +19,37 @@ export default class notificationService { throw error; } } + + static async updateNotifications(field, query) { + return await Notification.update(field, { where: query }); + } + + static async getNotifications(query, limit, page) { + const offset = (page - 1) * limit; + + const { count, rows } = await Notification.findAndCountAll({ + limit: limit, + offset: offset, + where: query, + order: [["createdAt", "DESC"]], + include: { + model: User, + as: "user", + attributes: { + exclude: ["bank", "microsoftId", "address"], + }, + }, + }); + + const totalPages = Math.ceil(count / limit); + const currentPage = page; + const totalItems = count; + return { totalPages, currentPage, totalItems, rows }; + } } + +export const knownNotificationType = { + projectCreated: "projectCreated", + projectAssigned: "projectAssigned", + projectCompleted: "projectCompleted", +}; diff --git a/src/system/utils/SocketUtil.js b/src/system/utils/SocketUtil.js new file mode 100644 index 0000000..4fbf9e0 --- /dev/null +++ b/src/system/utils/SocketUtil.js @@ -0,0 +1,23 @@ +import { Server } from "socket.io"; + +export class SocketUtil { + static io; + static socketEmit(key, data) { + SocketUtil.io.sockets.emit(key, data); + } + + static config(server) { + SocketUtil.io = new Server(server, { cors: { origin: "*" } }); + + SocketUtil.io.on("connection", (socket) => { + console.log("Connected", socket.id); + socket.on("join_room", (data) => { + socket.join(data); + }); + }); + } +} + +export const knownSockets = { + notification: "notification", +}; diff --git a/src/system/utils/event.util.js b/src/system/utils/event.util.js new file mode 100644 index 0000000..a13d3a5 --- /dev/null +++ b/src/system/utils/event.util.js @@ -0,0 +1,17 @@ +import EventEmitter from "events"; + +const myEmmiter = new EventEmitter(); + +export const knownEvents = { + projectCreated: "projectCreated", + addUserToProject: "addUserToProject", + addLeadToProject: "addLeadToProject", +}; + +export const subscribe = (eventName, callback) => { + myEmmiter.on(eventName, callback); +}; + +export const eventEmit = (eventName, data) => { + myEmmiter.emit(eventName, data); +}; diff --git a/src/system/utils/listenToEvent.js b/src/system/utils/listenToEvent.js new file mode 100644 index 0000000..81a3249 --- /dev/null +++ b/src/system/utils/listenToEvent.js @@ -0,0 +1,66 @@ +import { knownEvents, subscribe } from "./event.util"; +import sendEmail from "../../services/emailService"; +import NotificationService from "../../services/notificationService"; +import { knownSockets, SocketUtil } from "./SocketUtil"; + +const { createNotification } = NotificationService; + +export const listenToUserProjectAssigned = subscribe( + knownEvents.addUserToProject, + async (data) => { + const { user, project } = data; + const { email, firstName } = user.dataValues; + const { name, id: projectId } = project.dataValues; + + const userNotification = { + title: "Added to the project!", + description: `You have been added to the ${name} project`, + url: `${process.env.FRONTEND_URL}/projects/${projectId}`, + userId: user.id, + }; + + const notification = await createNotification(userNotification); + + SocketUtil.socketEmit( + `${knownSockets.notification}.${user.id}`, + notification + ); + + sendEmail( + email, + "Added to the project", + `Hello ${firstName}, You have been added to the "${name}" project`, + `${process.env.FRONTEND_URL}/projects/${projectId}` + ); + } +); + +export const listenToLeadProjectAssigned = subscribe( + knownEvents.addLeadToProject, + async (data) => { + const { leadUser, project } = data; + const { email, displayName, id: leadUserId } = leadUser.dataValues; + const { name: projectName, id: projectId } = project.dataValues; + + const leadNotification = { + title: "Added to the project as lead!", + description: `You have been added to the project "${projectName}" as the project lead`, + url: `${process.env.FRONTEND_URL}/projects/${projectId}`, + userId: leadUserId, + }; + + const notification = await createNotification(leadNotification); + + SocketUtil.socketEmit( + `${knownSockets.notification}.${leadUserId}`, + notification + ); + + sendEmail( + email, + "Added to the project as lead!", + `Hello ${displayName}, You have been added to the project "${projectName}" as the project lead.`, + `${process.env.FRONTEND_URL}/projects/${projectId}` + ); + } +); diff --git a/src/system/validators/index.js b/src/system/validators/index.js index 161d7cd..4602d35 100644 --- a/src/system/validators/index.js +++ b/src/system/validators/index.js @@ -1 +1,2 @@ +export * from "./pagination"; export * from "./rating"; diff --git a/src/system/validators/pagination.js b/src/system/validators/pagination.js new file mode 100644 index 0000000..62c6fe9 --- /dev/null +++ b/src/system/validators/pagination.js @@ -0,0 +1,8 @@ +import * as v from "valibot"; + +export const paginationSchema = v.object({ + limit: v.optional( + v.string([v.minValue(1, "Limit should be greater than 0.")]) + ), + page: v.optional(v.string([v.minValue(1, "Page should be greater than 0.")])), +});