From 6b8863ec92f134c37dddeb3f404063d65e541da9 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Thu, 12 Dec 2024 20:02:05 -0500 Subject: [PATCH 01/17] #3044 challenge handshake and event verification --- src/backend/index.ts | 2 + .../src/controllers/slack.controllers.ts | 42 +++++++++++++++++++ src/backend/src/routes/slack.routes.ts | 8 ++++ src/backend/src/services/slack.services.ts | 6 +++ src/backend/src/utils/auth.utils.ts | 9 ++-- 5 files changed, 64 insertions(+), 3 deletions(-) create mode 100644 src/backend/src/controllers/slack.controllers.ts create mode 100644 src/backend/src/routes/slack.routes.ts create mode 100644 src/backend/src/services/slack.services.ts diff --git a/src/backend/index.ts b/src/backend/index.ts index 97d683ec5f..79a1b99f37 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -17,6 +17,7 @@ import workPackageTemplatesRouter from './src/routes/work-package-templates.rout import carsRouter from './src/routes/cars.routes'; import organizationRouter from './src/routes/organizations.routes'; import recruitmentRouter from './src/routes/recruitment.routes'; +import slackRouter from './src/routes/slack.routes'; const app = express(); const port = process.env.PORT || 3001; @@ -67,6 +68,7 @@ app.use('/templates', workPackageTemplatesRouter); app.use('/cars', carsRouter); app.use('/organizations', organizationRouter); app.use('/recruitment', recruitmentRouter); +app.use('/slack', slackRouter); app.use('/', (_req, res) => { res.json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts new file mode 100644 index 0000000000..e3c042291b --- /dev/null +++ b/src/backend/src/controllers/slack.controllers.ts @@ -0,0 +1,42 @@ +import { Request, Response } from 'express'; +import crypto from 'crypto'; +import slackServices from '../services/slack.services'; + +export default class slackController { + static async handleEvent(req: Request, res: Response) { + console.log('got a slack req'); + if (req.body.type === 'url_verification') { + return res.status(200).send({ challenge: req.body.challenge }); + } + + const slackSignature = req.headers['x-slack-signature'] as string; + const slackTimeStamp = req.headers['X-Slack-Request-Timestamp'] as string; + + if (Math.abs(Date.now() - Number(slackTimeStamp) * 1000) > 60 * 5 * 1000) { + return res.status(400).send('Slack request verification failed due to expired timestamp'); + } + + const reqBody = req.body; + + const signatureBase = 'v0:' + slackTimeStamp + ':' + reqBody; + + const finalSignature = + 'v0=' + + crypto + .createHmac('sha256', process.env.SLACK_BOT_TOKEN ? process.env.SLACK_BOT_TOKEN : '') + .update(signatureBase) + .digest('hex'); + + if ( + crypto.timingSafeEqual( + Uint8Array.from(Buffer.from(finalSignature, 'utf8')), + Uint8Array.from(Buffer.from(slackSignature, 'utf8')) + ) + ) { + slackServices.processEvent(req.body); + return res.status(200).send('Event recieved'); + } + + return res.status(400).send('Slack request verification failed due to incorrect signature'); + } +} diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts new file mode 100644 index 0000000000..bea0984777 --- /dev/null +++ b/src/backend/src/routes/slack.routes.ts @@ -0,0 +1,8 @@ +import express from 'express'; +import slackController from '../controllers/slack.controllers'; + +const slackRouter = express.Router(); + +slackRouter.post('/', slackController.handleEvent); + +export default slackRouter; diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts new file mode 100644 index 0000000000..641c746314 --- /dev/null +++ b/src/backend/src/services/slack.services.ts @@ -0,0 +1,6 @@ +export default class slackServices { + static async processEvent(req: any ) { + //TODO: process request + console.log(req); + } +} diff --git a/src/backend/src/utils/auth.utils.ts b/src/backend/src/utils/auth.utils.ts index e8f647e57e..5f154073ff 100644 --- a/src/backend/src/utils/auth.utils.ts +++ b/src/backend/src/utils/auth.utils.ts @@ -31,7 +31,8 @@ export const requireJwtProd = (req: Request, res: Response, next: NextFunction) if ( req.path === '/users/auth/login' || // logins dont have cookies yet req.path === '/' || // base route is available so aws can listen and check the health - req.method === 'OPTIONS' // this is a pre-flight request and those don't send cookies + req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies + req.path === '/slack' // slack http endpoint is only used from slack api ) { return next(); } else if ( @@ -62,7 +63,8 @@ export const requireJwtDev = (req: Request, res: Response, next: NextFunction) = req.path === '/users/auth/login/dev' || // logins dont have cookies yet req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies - req.path === '/users' // dev login needs the list of users to log in + req.path === '/users' || // dev login needs the list of users to log in + req.path === '/slack' // slack http endpoint is only used from slack api ) { next(); } else if ( @@ -171,7 +173,8 @@ export const getUserAndOrganization = async (req: Request, res: Response, next: req.path === '/users/auth/login/dev' || req.path === '/' || // base route is available so aws can listen and check the health req.method === 'OPTIONS' || // this is a pre-flight request and those don't send cookies - req.path === '/users' // dev login needs the list of users to log in + req.path === '/users' || // dev login needs the list of users to log in + req.path === '/slack' // slack http endpoint is only used from slack api ) { return next(); } From 11291e84ed3dd4dba01e02493d85d403efa1b4f6 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 15:40:08 -0500 Subject: [PATCH 02/17] event-listening --- .../src/controllers/slack.controllers.ts | 43 +++++++++++++++++++ src/backend/src/routes/slack.routes.ts | 10 +++++ 2 files changed, 53 insertions(+) create mode 100644 src/backend/src/controllers/slack.controllers.ts create mode 100644 src/backend/src/routes/slack.routes.ts diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts new file mode 100644 index 0000000000..028272c075 --- /dev/null +++ b/src/backend/src/controllers/slack.controllers.ts @@ -0,0 +1,43 @@ +import { Request, Response } from 'express'; +import crypto from 'crypto'; +import slackServices from '../services/slack.services'; + +export default class slackController { + static async handleEvent(req: Request, res: Response) { + console.log('got a slack req'); + /* + if (req.body.type === 'url_verification') { + res.status(200).send({ challenge: req.body.challenge }); + } + */ + res.status(200).send({ challenge: req.body.challenge }); + + /* + const slackSignature = req.headers['x-slack-signature'] as string; + const slackTimeStamp = req.headers['X-Slack-Request-Timestamp'] as string; + + if (Math.abs(Date.now() - Number(slackTimeStamp) * 1000) > 60 * 5 * 1000) { + res.status(400).send('Slack request verification failed due to expired timestamp'); + } + + const reqBody = req.body; + + const signatureBase = 'v0:' + slackTimeStamp + ':' + reqBody; + + const finalSignature = + 'v0=' + crypto.createHmac('sha256', '124ce6afb575881aa36952d6abd4af0b').update(signatureBase).digest('hex'); + + if ( + crypto.timingSafeEqual( + Uint8Array.from(Buffer.from(finalSignature, 'utf8')), + Uint8Array.from(Buffer.from(slackSignature, 'utf8')) + ) + ) { + slackServices.processEvent(reqBody); + res.status(200).send('Event recieved'); + } + + res.status(400).send('Slack request verification failed due to incorrect signature'); + */ + } +} diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts new file mode 100644 index 0000000000..6e0c91e26e --- /dev/null +++ b/src/backend/src/routes/slack.routes.ts @@ -0,0 +1,10 @@ +import { slackEvents } from '../..'; + +slackEvents.on('message', async (event) => { + try { + console.log(`Message received: ${event.text}`); + // Respond or process the message as needed + } catch (error) { + console.error('Error handling message event:', error); + } +}); From 7a9985d68d8bb6950ebddf282686c8e0ba656fa6 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 19:35:07 -0500 Subject: [PATCH 03/17] #3044-slack event received --- .../src/controllers/slack.controllers.ts | 27 ++++++++++++------- src/backend/src/services/slack.services.ts | 2 +- 2 files changed, 19 insertions(+), 10 deletions(-) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index e3c042291b..51388961a6 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -1,42 +1,51 @@ import { Request, Response } from 'express'; -import crypto from 'crypto'; +//import crypto from 'crypto'; import slackServices from '../services/slack.services'; export default class slackController { static async handleEvent(req: Request, res: Response) { console.log('got a slack req'); if (req.body.type === 'url_verification') { - return res.status(200).send({ challenge: req.body.challenge }); + res.status(200).send({ challenge: req.body.challenge }); } - const slackSignature = req.headers['x-slack-signature'] as string; + console.log('EVENT'); + + //const slackSignature = req.headers['x-slack-signature'] as string; const slackTimeStamp = req.headers['X-Slack-Request-Timestamp'] as string; if (Math.abs(Date.now() - Number(slackTimeStamp) * 1000) > 60 * 5 * 1000) { - return res.status(400).send('Slack request verification failed due to expired timestamp'); + res.status(400).send('Slack request verification failed due to expired timestamp'); } - + /* const reqBody = req.body; const signatureBase = 'v0:' + slackTimeStamp + ':' + reqBody; - + const finalSignature = 'v0=' + crypto .createHmac('sha256', process.env.SLACK_BOT_TOKEN ? process.env.SLACK_BOT_TOKEN : '') .update(signatureBase) .digest('hex'); + */ + console.log('PROCESSING'); + slackServices.processEvent(req.body); + res.status(200).send('Event recieved'); + /* if ( crypto.timingSafeEqual( Uint8Array.from(Buffer.from(finalSignature, 'utf8')), Uint8Array.from(Buffer.from(slackSignature, 'utf8')) ) ) { + console.log('WORKING'); slackServices.processEvent(req.body); - return res.status(200).send('Event recieved'); + res.status(200).send('Event recieved'); } - - return res.status(400).send('Slack request verification failed due to incorrect signature'); + console.log('INVALID SIGNITURE'); + res.status(400).send('Slack request verification failed due to incorrect signature'); + */ } } diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 641c746314..20cbd3039f 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,5 +1,5 @@ export default class slackServices { - static async processEvent(req: any ) { + static async processEvent(req: any) { //TODO: process request console.log(req); } From 5516efd2d863dd29cf289988fc8bc9733cea429b Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Sun, 15 Dec 2024 19:55:14 -0500 Subject: [PATCH 04/17] #3044-receiving events --- package.json | 1 + src/backend/index.ts | 5 + .../src/controllers/slack.controllers.ts | 43 ------ src/backend/src/routes/slack.routes.ts | 11 +- yarn.lock | 130 +++++++++++++++++- 5 files changed, 134 insertions(+), 56 deletions(-) delete mode 100644 src/backend/src/controllers/slack.controllers.ts diff --git a/package.json b/package.json index bc13987bec..6530b87e3e 100644 --- a/package.json +++ b/package.json @@ -51,6 +51,7 @@ "@types/react-dom": "17.0.1" }, "dependencies": { + "@slack/events-api": "^3.0.1", "mitt": "^3.0.1", "react-hook-form-persist": "^3.0.0", "typescript": "^4.1.5" diff --git a/src/backend/index.ts b/src/backend/index.ts index babf50b843..abe9cc0a61 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -17,6 +17,7 @@ import workPackageTemplatesRouter from './src/routes/work-package-templates.rout import carsRouter from './src/routes/cars.routes'; import organizationRouter from './src/routes/organizations.routes'; import recruitmentRouter from './src/routes/recruitment.routes'; +import { slackEvents } from './src/routes/slack.routes'; const app = express(); @@ -40,6 +41,10 @@ const options: cors.CorsOptions = { allowedHeaders }; +// so we can listen to slack messages +// NOTE: must be done before using json +app.use('/slack', slackEvents.requestListener()); + // so that we can use cookies and json app.use(cookieParser()); app.use(express.json()); diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts deleted file mode 100644 index 028272c075..0000000000 --- a/src/backend/src/controllers/slack.controllers.ts +++ /dev/null @@ -1,43 +0,0 @@ -import { Request, Response } from 'express'; -import crypto from 'crypto'; -import slackServices from '../services/slack.services'; - -export default class slackController { - static async handleEvent(req: Request, res: Response) { - console.log('got a slack req'); - /* - if (req.body.type === 'url_verification') { - res.status(200).send({ challenge: req.body.challenge }); - } - */ - res.status(200).send({ challenge: req.body.challenge }); - - /* - const slackSignature = req.headers['x-slack-signature'] as string; - const slackTimeStamp = req.headers['X-Slack-Request-Timestamp'] as string; - - if (Math.abs(Date.now() - Number(slackTimeStamp) * 1000) > 60 * 5 * 1000) { - res.status(400).send('Slack request verification failed due to expired timestamp'); - } - - const reqBody = req.body; - - const signatureBase = 'v0:' + slackTimeStamp + ':' + reqBody; - - const finalSignature = - 'v0=' + crypto.createHmac('sha256', '124ce6afb575881aa36952d6abd4af0b').update(signatureBase).digest('hex'); - - if ( - crypto.timingSafeEqual( - Uint8Array.from(Buffer.from(finalSignature, 'utf8')), - Uint8Array.from(Buffer.from(slackSignature, 'utf8')) - ) - ) { - slackServices.processEvent(reqBody); - res.status(200).send('Event recieved'); - } - - res.status(400).send('Slack request verification failed due to incorrect signature'); - */ - } -} diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 6e0c91e26e..ebefdc8840 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -1,10 +1,7 @@ -import { slackEvents } from '../..'; +import { createEventAdapter } from '@slack/events-api'; + +export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); slackEvents.on('message', async (event) => { - try { - console.log(`Message received: ${event.text}`); - // Respond or process the message as needed - } catch (error) { - console.error('Error handling message event:', error); - } + console.log('EVENT:', event); }); diff --git a/yarn.lock b/yarn.lock index 139b47dd19..3ae1a42802 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3260,6 +3260,30 @@ __metadata: languageName: node linkType: hard +"@slack/events-api@npm:^3.0.1": + version: 3.0.1 + resolution: "@slack/events-api@npm:3.0.1" + dependencies: + "@types/debug": ^4.1.4 + "@types/express": ^4.17.0 + "@types/lodash.isstring": ^4.0.6 + "@types/node": ">=12.13.0 < 13" + "@types/yargs": ^15.0.4 + debug: ^2.6.1 + express: ^4.0.0 + lodash.isstring: ^4.0.1 + raw-body: ^2.3.3 + tsscmp: ^1.0.6 + yargs: ^15.3.1 + dependenciesMeta: + express: + optional: true + bin: + slack-verify: dist/verify.js + checksum: ce62dc2ee9dd93b88820e18f88f543228740243dc390caf49b3a7e1ad351b298e3961898bd78f5eb43e9f6acac067458257cd34c9661089f684bb5cf4af468c3 + languageName: node + linkType: hard + "@slack/logger@npm:^3.0.0": version: 3.0.0 resolution: "@slack/logger@npm:3.0.0" @@ -3706,7 +3730,7 @@ __metadata: languageName: node linkType: hard -"@types/debug@npm:^4.1.7": +"@types/debug@npm:^4.1.4, @types/debug@npm:^4.1.7": version: 4.1.12 resolution: "@types/debug@npm:4.1.12" dependencies: @@ -3756,6 +3780,18 @@ __metadata: languageName: node linkType: hard +"@types/express-serve-static-core@npm:^4.17.33": + version: 4.19.6 + resolution: "@types/express-serve-static-core@npm:4.19.6" + dependencies: + "@types/node": "*" + "@types/qs": "*" + "@types/range-parser": "*" + "@types/send": "*" + checksum: b0576eddc2d25ccdf10e68ba09598b87a4d7b2ad04a81dc847cb39fe56beb0b6a5cc017b1e00aa0060cb3b38e700384ce96d291a116a0f1e54895564a104aae9 + languageName: node + linkType: hard + "@types/express-serve-static-core@npm:^5.0.0": version: 5.0.2 resolution: "@types/express-serve-static-core@npm:5.0.2" @@ -3789,6 +3825,18 @@ __metadata: languageName: node linkType: hard +"@types/express@npm:^4.17.0": + version: 4.17.21 + resolution: "@types/express@npm:4.17.21" + dependencies: + "@types/body-parser": "*" + "@types/express-serve-static-core": ^4.17.33 + "@types/qs": "*" + "@types/serve-static": "*" + checksum: fb238298630370a7392c7abdc80f495ae6c716723e114705d7e3fb67e3850b3859bbfd29391463a3fb8c0b32051847935933d99e719c0478710f8098ee7091c5 + languageName: node + linkType: hard + "@types/file-saver@npm:^2.0.5": version: 2.0.7 resolution: "@types/file-saver@npm:2.0.7" @@ -3939,7 +3987,16 @@ __metadata: languageName: node linkType: hard -"@types/lodash@npm:^4.14.175": +"@types/lodash.isstring@npm:^4.0.6": + version: 4.0.9 + resolution: "@types/lodash.isstring@npm:4.0.9" + dependencies: + "@types/lodash": "*" + checksum: ef381be69b459caa42d7c5dc4ff5b3653e6b3c9b2393f6e92848efeafe7690438e058b26f036b11b4e535fc7645ff12d1203847b9a82e9ae0593bdd3b25a971b + languageName: node + linkType: hard + +"@types/lodash@npm:*, @types/lodash@npm:^4.14.175": version: 4.17.13 resolution: "@types/lodash@npm:4.17.13" checksum: d0bf8fbd950be71946e0076b30fd40d492293baea75f05931b6b5b906fd62583708c6229abdb95b30205ad24ce1ed2f48bc9d419364f682320edd03405cc0c7e @@ -4008,6 +4065,13 @@ __metadata: languageName: node linkType: hard +"@types/node@npm:>=12.13.0 < 13": + version: 12.20.55 + resolution: "@types/node@npm:12.20.55" + checksum: e4f86785f4092706e0d3b0edff8dca5a13b45627e4b36700acd8dfe6ad53db71928c8dee914d4276c7fd3b6ccd829aa919811c9eb708a2c8e4c6eb3701178c37 + languageName: node + linkType: hard + "@types/nodemailer@npm:^6.4.0": version: 6.4.17 resolution: "@types/nodemailer@npm:6.4.17" @@ -4292,7 +4356,7 @@ __metadata: languageName: node linkType: hard -"@types/yargs@npm:^15.0.0": +"@types/yargs@npm:^15.0.0, @types/yargs@npm:^15.0.4": version: 15.0.19 resolution: "@types/yargs@npm:15.0.19" dependencies: @@ -8054,7 +8118,7 @@ __metadata: languageName: node linkType: hard -"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3, debug@npm:^2.6.0": +"debug@npm:2.6.9, debug@npm:^2.2.0, debug@npm:^2.3.3, debug@npm:^2.6.0, debug@npm:^2.6.1": version: 2.6.9 resolution: "debug@npm:2.6.9" dependencies: @@ -10062,6 +10126,45 @@ __metadata: languageName: node linkType: hard +"express@npm:^4.0.0": + version: 4.21.2 + resolution: "express@npm:4.21.2" + dependencies: + accepts: ~1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: ~1.0.4 + cookie: 0.7.1 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: ~2.0.0 + escape-html: ~1.0.3 + etag: ~1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: ~1.1.2 + on-finished: 2.4.1 + parseurl: ~1.3.3 + path-to-regexp: 0.1.12 + proxy-addr: ~2.0.7 + qs: 6.13.0 + range-parser: ~1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: ~1.6.18 + utils-merge: 1.0.1 + vary: ~1.1.2 + checksum: 3aef1d355622732e20b8f3a7c112d4391d44e2131f4f449e1f273a309752a41abfad714e881f177645517cbe29b3ccdc10b35e7e25c13506114244a5b72f549d + languageName: node + linkType: hard + "express@npm:^4.17.1": version: 4.21.1 resolution: "express@npm:4.21.1" @@ -10464,6 +10567,7 @@ __metadata: "@babel/plugin-transform-object-assign": ^7.18.6 "@babel/preset-react": ^7.18.6 "@babel/preset-typescript": ^7.18.6 + "@slack/events-api": ^3.0.1 "@types/jest": ^28.1.6 "@types/node": 18.17.1 "@typescript-eslint/eslint-plugin": 4.18.0 @@ -15863,6 +15967,13 @@ __metadata: languageName: node linkType: hard +"path-to-regexp@npm:0.1.12": + version: 0.1.12 + resolution: "path-to-regexp@npm:0.1.12" + checksum: ab237858bee7b25ecd885189f175ab5b5161e7b712b360d44f5c4516b8d271da3e4bf7bf0a7b9153ecb04c7d90ce8ff5158614e1208819cf62bac2b08452722e + languageName: node + linkType: hard + "path-to-regexp@npm:^1.7.0": version: 1.9.0 resolution: "path-to-regexp@npm:1.9.0" @@ -17379,7 +17490,7 @@ __metadata: languageName: node linkType: hard -"raw-body@npm:2.5.2": +"raw-body@npm:2.5.2, raw-body@npm:^2.3.3": version: 2.5.2 resolution: "raw-body@npm:2.5.2" dependencies: @@ -20522,6 +20633,13 @@ __metadata: languageName: node linkType: hard +"tsscmp@npm:^1.0.6": + version: 1.0.6 + resolution: "tsscmp@npm:1.0.6" + checksum: 1512384def36bccc9125cabbd4c3b0e68608d7ee08127ceaa0b84a71797263f1a01c7f82fa69be8a3bd3c1396e2965d2f7b52d581d3a5eeaf3967fbc52e3b3bf + languageName: node + linkType: hard + "tsutils@npm:^3.17.1, tsutils@npm:^3.21.0": version: 3.21.0 resolution: "tsutils@npm:3.21.0" @@ -22349,7 +22467,7 @@ __metadata: languageName: node linkType: hard -"yargs@npm:^15.4.1": +"yargs@npm:^15.3.1, yargs@npm:^15.4.1": version: 15.4.1 resolution: "yargs@npm:15.4.1" dependencies: From 35e2a275213943267a2991cf28f42b7249b4ddba Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Wed, 18 Dec 2024 14:31:01 -0500 Subject: [PATCH 05/17] #2823 merge with getUnreadAnnouncements and slack message processing functionality --- .../src/controllers/users.controllers.ts | 25 ++++ .../announcements.query.args.ts | 11 ++ .../migration.sql | 8 -- .../migration.sql | 9 -- .../migration.sql | 19 ++- src/backend/src/prisma/schema.prisma | 22 +-- src/backend/src/prisma/seed.ts | 2 - src/backend/src/routes/slack.routes.ts | 2 +- src/backend/src/routes/users.routes.ts | 6 + .../src/services/announcement.service.ts | 86 ++++++++++++ .../src/services/change-requests.services.ts | 5 + .../src/services/design-reviews.services.ts | 3 + .../src/services/notifications.services.ts | 12 +- src/backend/src/services/slack.services.ts | 126 ++++++++++++++---- src/backend/src/services/users.services.ts | 53 ++++++++ .../transformers/announcements.transformer.ts | 19 +++ .../transformers/notifications.transformer.ts | 3 +- src/backend/src/utils/errors.utils.ts | 3 +- src/backend/src/utils/notifications.utils.ts | 79 ++++++++++- src/backend/tests/unmocked/users.test.ts | 33 +++++ src/frontend/src/apis/users.api.ts | 17 +++ .../src/components/NotificationAlert.tsx | 57 ++++++++ .../src/components/NotificationCard.tsx | 73 ++++++++++ src/frontend/src/hooks/users.hooks.ts | 40 +++++- src/frontend/src/pages/HomePage/Home.tsx | 26 ++-- src/frontend/src/utils/urls.ts | 4 + src/shared/index.ts | 1 + src/shared/src/types/announcements.types.ts | 12 ++ src/shared/src/types/notifications.types.ts | 1 + 29 files changed, 681 insertions(+), 76 deletions(-) create mode 100644 src/backend/src/prisma-query-args/announcements.query.args.ts delete mode 100644 src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql delete mode 100644 src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql rename src/backend/src/prisma/migrations/{20241211195435_announcements_and_notifications => 20241218160143_homepage_updates}/migration.sql (73%) create mode 100644 src/backend/src/services/announcement.service.ts create mode 100644 src/backend/src/transformers/announcements.transformer.ts create mode 100644 src/frontend/src/components/NotificationAlert.tsx create mode 100644 src/frontend/src/components/NotificationCard.tsx create mode 100644 src/shared/src/types/announcements.types.ts diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index e491d3f4f4..cc084c714e 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -203,4 +203,29 @@ export default class UsersController { next(error); } } + + static async removeUserNotification(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { notificationId } = req.body; + const { organization } = req; + + const unreadNotifications = await UsersService.removeUserNotification(userId, notificationId, organization); + res.status(200).json(unreadNotifications); + } catch (error: unknown) { + next(error); + } + } + + static async getUserUnreadAnnouncements(req: Request, res: Response, next: NextFunction) { + try { + const { userId } = req.params; + const { organization } = req; + + const unreadAnnouncements = await UsersService.getUserUnreadAnnouncements(userId, organization); + res.status(200).json(unreadAnnouncements); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/prisma-query-args/announcements.query.args.ts b/src/backend/src/prisma-query-args/announcements.query.args.ts new file mode 100644 index 0000000000..b88c9fbf1d --- /dev/null +++ b/src/backend/src/prisma-query-args/announcements.query.args.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args'; + +export type AnnouncementQueryArgs = ReturnType; + +export const getAnnouncementQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + usersReceived: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql b/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql deleted file mode 100644 index fe5771a6b1..0000000000 --- a/src/backend/src/prisma/migrations/20240910005616_add_logo_image_featured_project/migration.sql +++ /dev/null @@ -1,8 +0,0 @@ --- AlterTable -ALTER TABLE "Organization" ADD COLUMN "logoImage" TEXT; - --- AlterTable -ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; - --- AddForeignKey -ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql b/src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql deleted file mode 100644 index 2ddd9fa7b6..0000000000 --- a/src/backend/src/prisma/migrations/20240911164338_changed_logo_image_name_to_logo_image_id/migration.sql +++ /dev/null @@ -1,9 +0,0 @@ -/* - Warnings: - - - You are about to drop the column `logoImage` on the `Organization` table. All the data in the column will be lost. - -*/ --- AlterTable -ALTER TABLE "Organization" DROP COLUMN "logoImage", -ADD COLUMN "logoImageId" TEXT; diff --git a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql b/src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql similarity index 73% rename from src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql rename to src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql index c57afabd25..37c468e58e 100644 --- a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql +++ b/src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql @@ -1,9 +1,18 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; + -- CreateTable CREATE TABLE "Announcement" ( "announcementId" TEXT NOT NULL, "text" TEXT NOT NULL, - "dateCrated" TIMESTAMP(3) NOT NULL, - "userCreatedId" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL, + "dateDeleted" TIMESTAMP(3), + "senderName" TEXT NOT NULL, + "slackEventId" TEXT NOT NULL, + "slackChannelName" TEXT NOT NULL, CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") ); @@ -13,6 +22,7 @@ CREATE TABLE "Notification" ( "notificationId" TEXT NOT NULL, "text" TEXT NOT NULL, "iconName" TEXT NOT NULL, + "eventLink" TEXT, CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") ); @@ -29,6 +39,9 @@ CREATE TABLE "_userNotifications" ( "B" TEXT NOT NULL ); +-- CreateIndex +CREATE UNIQUE INDEX "Announcement_slackEventId_key" ON "Announcement"("slackEventId"); + -- CreateIndex CREATE UNIQUE INDEX "_receivedAnnouncements_AB_unique" ON "_receivedAnnouncements"("A", "B"); @@ -42,7 +55,7 @@ CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A", CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("B"); -- AddForeignKey -ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_userCreatedId_fkey" FOREIGN KEY ("userCreatedId") REFERENCES "User"("userId") ON DELETE RESTRICT ON UPDATE CASCADE; +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; -- AddForeignKey ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_A_fkey" FOREIGN KEY ("A") REFERENCES "Announcement"("announcementId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index c06ca3d45f..bb8247266d 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -180,8 +180,7 @@ model User { deletedFrequentlyAskedQuestions FrequentlyAskedQuestion[] @relation(name: "frequentlyAskedQuestionDeleter") createdMilestones Milestone[] @relation(name: "milestoneCreator") deletedMilestones Milestone[] @relation(name: "milestoneDeleter") - receivedAnnouncements Announcement[] @relation(name: "receivedAnnouncements") - createdAnnouncements Announcement[] @relation(name: "createdAnnouncements") + unreadAnnouncements Announcement[] @relation(name: "receivedAnnouncements") unreadNotifications Notification[] @relation(name: "userNotifications") } @@ -932,17 +931,20 @@ model Milestone { } model Announcement { - announcementId String @id @default(uuid()) - text String - usersReceived User[] @relation("receivedAnnouncements") - dateCrated DateTime - userCreatedId String - userCreated User @relation("createdAnnouncements", fields: [userCreatedId], references: [userId]) + announcementId String @id @default(uuid()) + text String + usersReceived User[] @relation("receivedAnnouncements") + dateCreated DateTime + dateDeleted DateTime? + senderName String + slackEventId String @unique + slackChannelName String } model Notification { - notificationId String @id @default(uuid()) + notificationId String @id @default(uuid()) text String iconName String - users User[] @relation("userNotifications") + users User[] @relation("userNotifications") + eventLink String? } diff --git a/src/backend/src/prisma/seed.ts b/src/backend/src/prisma/seed.ts index d19e61a98f..fdb5ffefdb 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -1894,8 +1894,6 @@ const performSeed: () => Promise = async () => { await RecruitmentServices.createFaq(batman, 'When was FinishLine created?', 'FinishLine was created in 2019', ner); await RecruitmentServices.createFaq(batman, 'How many developers are working on FinishLine?', '178 as of 2024', ner); - - await NotificationsService.sendNotifcationToUsers('Admin!', 'star', [thomasEmrax.userId], ner.organizationId); }; performSeed() diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index e88ed59893..a938ac0691 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -5,7 +5,7 @@ export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET | slackEvents.on('message', async (event) => { console.log('EVENT:', event); - slackServices.processMessageSent(event); + slackServices.processMessageSent(event, process.env.DEV_ORGANIZATION_ID ?? ''); }); slackEvents.on('error', (error) => { diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 34ae1a0136..802d586500 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -55,5 +55,11 @@ userRouter.post( UsersController.getManyUserTasks ); userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications); +userRouter.get('/:userId/announcements', UsersController.getUserUnreadAnnouncements); +userRouter.post( + '/:userId/notifications/remove', + nonEmptyString(body('notificationId')), + UsersController.removeUserNotification +); export default userRouter; diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts new file mode 100644 index 0000000000..c4199a40a8 --- /dev/null +++ b/src/backend/src/services/announcement.service.ts @@ -0,0 +1,86 @@ +import { Announcement } from 'shared'; +import prisma from '../prisma/prisma'; +import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import announcementTransformer from '../transformers/announcements.transformer'; +import { NotFoundException } from '../utils/errors.utils'; + +export default class AnnouncementService { + static async createAnnouncement( + text: string, + usersReceivedIds: string[], + dateCreated: Date, + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const announcement = await prisma.announcement.create({ + data: { + text, + usersReceived: { + connect: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + dateCreated, + senderName, + slackEventId, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async UpdateAnnouncement( + text: string, + usersReceivedIds: string[], + dateCreated: Date, + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + + const announcement = await prisma.announcement.update({ + where: { announcementId: originalAnnouncement.announcementId }, + data: { + text, + usersReceived: { + connect: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + slackEventId, + dateCreated, + senderName, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async DeleteAnnouncement(slackEventId: string, organizationId: string): Promise { + const announcement = await prisma.announcement.update({ + where: { slackEventId }, + data: { + dateDeleted: new Date() + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + if (!announcement) throw new NotFoundException('Announcement', slackEventId); + + return announcementTransformer(announcement); + } +} diff --git a/src/backend/src/services/change-requests.services.ts b/src/backend/src/services/change-requests.services.ts index 89cbaaa370..85e0dc5579 100644 --- a/src/backend/src/services/change-requests.services.ts +++ b/src/backend/src/services/change-requests.services.ts @@ -46,6 +46,7 @@ import { import { ChangeRequestQueryArgs, getChangeRequestQueryArgs } from '../prisma-query-args/change-requests.query-args'; import proposedSolutionTransformer from '../transformers/proposed-solutions.transformer'; import { getProposedSolutionQueryArgs } from '../prisma-query-args/proposed-solutions.query-args'; +import { sendHomeCrRequestReviewNotification, sendHomeCrReviewedNotification } from '../utils/notifications.utils'; export default class ChangeRequestsService { /** @@ -150,6 +151,8 @@ export default class ChangeRequestsService { // send a notification to the submitter that their change request has been reviewed await sendCRSubmitterReviewedNotification(updated); + await sendHomeCrReviewedNotification(foundCR, updated.submitter, accepted, organization.organizationId); + // send a reply to a CR's notifications of its updated status await sendSlackCRStatusToThread(updated.notificationSlackThreads, foundCR.crId, foundCR.identifier, accepted); @@ -1078,5 +1081,7 @@ export default class ChangeRequestsService { // send slack message to CR reviewers await sendSlackRequestedReviewNotification(newReviewers, changeRequestTransformer(foundCR)); + + await sendHomeCrRequestReviewNotification(foundCR, newReviewers, organization.organizationId); } } diff --git a/src/backend/src/services/design-reviews.services.ts b/src/backend/src/services/design-reviews.services.ts index 8f7511b73c..644903fa43 100644 --- a/src/backend/src/services/design-reviews.services.ts +++ b/src/backend/src/services/design-reviews.services.ts @@ -39,6 +39,7 @@ import { getWorkPackageQueryArgs } from '../prisma-query-args/work-packages.quer import { UserWithSettings } from '../utils/auth.utils'; import { getUserScheduleSettingsQueryArgs } from '../prisma-query-args/user.query-args'; import { createCalendarEvent, deleteCalendarEvent, updateCalendarEvent } from '../utils/google-integration.utils'; +import { sendHomeDrNotification } from '../utils/notifications.utils'; export default class DesignReviewsService { /** @@ -205,6 +206,8 @@ export default class DesignReviewsService { } } + await sendHomeDrNotification(designReview, members, submitter, wbsElement.name, organization.organizationId); + const project = wbsElement.workPackage?.project; const teams = project?.teams; if (teams && teams.length > 0) { diff --git a/src/backend/src/services/notifications.services.ts b/src/backend/src/services/notifications.services.ts index 483e6ed9d6..e0617301f5 100644 --- a/src/backend/src/services/notifications.services.ts +++ b/src/backend/src/services/notifications.services.ts @@ -202,13 +202,21 @@ export default class NotificationsService { * @param iconName icon that appears in the notification * @param userIds ids of users to send the notification to * @param organizationId + * @param eventLink link the notification will go to when clicked * @returns the created notification */ - static async sendNotifcationToUsers(text: string, iconName: string, userIds: string[], organizationId: string) { + static async sendNotifcationToUsers( + text: string, + iconName: string, + userIds: string[], + organizationId: string, + eventLink?: string + ) { const createdNotification = await prisma.notification.create({ data: { text, - iconName + iconName, + eventLink }, ...getNotificationQueryArgs(organizationId) }); diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index a1fc3e3a0f..d9d028adbf 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,24 +1,38 @@ -import { UserWithScheduleSettings } from 'shared'; import UsersService from './users.services'; -import { WebClient } from '@slack/web-api'; import { getChannelName, getUserName, getUsersInChannel } from '../integrations/slack'; -import { UserWithId } from '../utils/teams.utils'; -import { UserWithSecureSettings, UserWithSettings } from '../utils/auth.utils'; import { User_Settings } from '@prisma/client'; -import NotificationsService from './notifications.services'; - -const slack = new WebClient(process.env.SLACK_BOT_TOKEN); +import AnnouncementService from './announcement.service'; +import { Announcement } from 'shared'; export interface SlackMessageEvent { - type: string; + type: 'message'; subtype?: string; channel: string; - user: string; - text: string; - ts: string; event_ts: string; channel_type: string; - blocks: any; +} + +export interface SlackMessage extends SlackMessageEvent { + user: string; + type: 'message'; + client_msg_id: string; + text: string; + blocks: { + type: string; + block_id: string; + elements: any[]; + }[]; +} + +export interface SlackDeletedMessage extends SlackMessageEvent { + subtype: 'message_deleted'; + previous_message: SlackMessage; +} + +export interface SlackUpdatedMessage extends SlackMessageEvent { + subtype: 'message_changed'; + message: SlackMessage; + previous_message: SlackMessage; } export interface SlackRichTextBlock { @@ -65,11 +79,11 @@ export default class slackServices { case 'text': return block.text ?? ''; case 'user': - let userName = block.user_id; + let userName: string = block.user_id ?? ''; try { - userName = await getUserName(block.user_id ?? ''); + userName = (await getUserName(block.user_id ?? '')) ?? `Unknown User:${block.user_id}`; } catch (error) { - userName = `ISSUE PARSING USER:${block.user_id}`; + userName = `Unknown_User:${block.user_id}`; } return '@' + userName; case 'usergroup': @@ -107,9 +121,35 @@ export default class slackServices { } } - static async processMessageSent(event: SlackMessageEvent) { + static async processMessageSent(event: SlackMessageEvent, organizationId: string): Promise { + const slackChannelName = await getChannelName(event.channel); + const dateCreated = new Date(Number(event.event_ts)); + + let eventMessage: SlackMessage; + + if (event.subtype) { + switch (event.subtype) { + case 'message_deleted': + eventMessage = (event as SlackDeletedMessage).previous_message; + try { + return AnnouncementService.DeleteAnnouncement(eventMessage.client_msg_id, organizationId); + } catch (ignored) { + return; + } + case 'message_changed': + eventMessage = (event as SlackUpdatedMessage).message; + break; + default: + //other events that do not effect announcements + return; + } + } else { + eventMessage = event as SlackMessage; + } + let messageText = ''; let userIdsToNotify: string[] = []; + const users = await UsersService.getAllUsers(); const userSettings = await Promise.all( users.map((user) => { @@ -117,22 +157,56 @@ export default class slackServices { }) ); - const richTextBlocks = event.blocks?.filter((eventBlock: any) => eventBlock.type === 'rich_text'); + let userName: string = ''; + try { + userName = (await getUserName(eventMessage.user)) ?? ''; + } catch (ignored) {} + + if (!userName) { + const userIdList = userSettings + .filter((userSetting) => userSetting.slackId === eventMessage.user) + .map((userSettings) => userSettings.userId); + if (userIdList.length !== 0) { + userName = users.find((user) => user.userId === userIdList[0])?.firstName ?? 'Unknown User:' + eventMessage.user; + } else { + userName = 'Unknown_User:' + eventMessage.user; + } + } + + const richTextBlocks = eventMessage.blocks?.filter((eventBlock: any) => eventBlock.type === 'rich_text'); if (richTextBlocks && richTextBlocks.length === 1) { for (const element of richTextBlocks[0].elements[0].elements) { messageText += await slackServices.blockToString(element); - userIdsToNotify = userIdsToNotify.concat(await slackServices.blockToMentionedUsers(element, userSettings, '')); + userIdsToNotify = userIdsToNotify.concat( + await slackServices.blockToMentionedUsers(element, userSettings, event.channel) + ); } + } else { + return; } - // if (event.subtype) { - // switch (event.subtype) { - // case '': - // } - // } - - // console.log(event.blocks.elements); - console.log(event.type === 'message'); + if (event.subtype === 'message_changed') { + try { + return AnnouncementService.UpdateAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } catch (ignored) {} + } + return AnnouncementService.createAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); } } diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index d9d37b259e..fff25ef56b 100644 --- a/src/backend/src/services/users.services.ts +++ b/src/backend/src/services/users.services.ts @@ -40,6 +40,8 @@ import { getTaskQueryArgs } from '../prisma-query-args/tasks.query-args'; import taskTransformer from '../transformers/tasks.transformer'; import { getNotificationQueryArgs } from '../prisma-query-args/notifications.query-args'; import notificationTransformer from '../transformers/notifications.transformer'; +import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import announcementTransformer from '../transformers/announcements.transformer'; export default class UsersService { /** @@ -569,6 +571,12 @@ export default class UsersService { return resolvedTasks.flat(); } + /** + * Gets all of a user's unread notifications + * @param userId id of user to get unread notifications from + * @param organization the user's orgainzation + * @returns the unread notifications of the user + */ static async getUserUnreadNotifications(userId: string, organization: Organization) { const requestedUser = await prisma.user.findUnique({ where: { userId }, @@ -578,4 +586,49 @@ export default class UsersService { return requestedUser.unreadNotifications.map(notificationTransformer); } + + /** + * Gets all of a user's unread announcements + * @param userId id of user to get unread announcements from + * @param organization the user's orgainzation + * @returns the unread announcements of the user + */ + static async getUserUnreadAnnouncements(userId: string, organization: Organization) { + const requestedUser = await prisma.user.findUnique({ + where: { userId }, + include: { unreadAnnouncements: getAnnouncementQueryArgs(organization.organizationId) } + }); + if (!requestedUser) throw new NotFoundException('User', userId); + + return requestedUser.unreadAnnouncements.map(announcementTransformer); + } + + /** + * Removes a notification from the user's unread notifications + * @param userId id of the user to remove notification from + * @param notificationId id of the notification to remove + * @param organization the user's organization + * @returns the user's updated unread notifications + */ + static async removeUserNotification(userId: string, notificationId: string, organization: Organization) { + const requestedUser = await prisma.user.findUnique({ + where: { userId } + }); + + if (!requestedUser) throw new NotFoundException('User', userId); + + const updatedUser = await prisma.user.update({ + where: { userId }, + data: { + unreadNotifications: { + disconnect: { + notificationId + } + } + }, + include: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } + }); + + return updatedUser.unreadNotifications.map(notificationTransformer); + } } diff --git a/src/backend/src/transformers/announcements.transformer.ts b/src/backend/src/transformers/announcements.transformer.ts new file mode 100644 index 0000000000..4fee90eac2 --- /dev/null +++ b/src/backend/src/transformers/announcements.transformer.ts @@ -0,0 +1,19 @@ +import { Prisma } from '@prisma/client'; +import { AnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import { Announcement } from 'shared'; +import { userTransformer } from './user.transformer'; + +const announcementTransformer = (announcement: Prisma.AnnouncementGetPayload): Announcement => { + return { + announcementId: announcement.announcementId, + text: announcement.text, + usersReceived: announcement.usersReceived.map(userTransformer), + dateCreated: announcement.dateCreated, + senderName: announcement.senderName, + slackEventId: announcement.slackEventId, + slackChannelName: announcement.slackChannelName, + dateDeleted: announcement.dateDeleted ?? undefined + }; +}; + +export default announcementTransformer; diff --git a/src/backend/src/transformers/notifications.transformer.ts b/src/backend/src/transformers/notifications.transformer.ts index 32666b151a..45dd25dee9 100644 --- a/src/backend/src/transformers/notifications.transformer.ts +++ b/src/backend/src/transformers/notifications.transformer.ts @@ -6,7 +6,8 @@ const notificationTransformer = (notification: Prisma.NotificationGetPayload { endOfDay.setDate(startOfDay.getDate() + 1); return endOfDay; }; + +/** + * Sends a finishline notification that a design review was scheduled + * @param designReview dr that was created + * @param members optional and required members of the dr + * @param submitter the user who created the dr + * @param workPackageName the name of the work package associated witht the dr + * @param organizationId id of the organization of the dr + */ +export const sendHomeDrNotification = async ( + designReview: Design_Review, + members: User[], + submitter: User, + workPackageName: string, + organizationId: string +) => { + const designReviewLink = `/settings/preferences?drId=${designReview.designReviewId}`; + + const msg = `Design Review for ${workPackageName} is being scheduled by ${submitter.firstName} ${submitter.lastName}`; + await NotificationsService.sendNotifcationToUsers( + msg, + 'calendar_month', + members.map((member) => member.userId), + organizationId, + designReviewLink + ); +}; + +/** + * Sends a finishline notification that a change request was reviewed + * @param changeRequest cr that was requested review + * @param submitter the user who submitted the cr + * @param accepted true if the cr changes were accepted, false if denied + * @param organizationId id of the organization of the cr + */ +export const sendHomeCrReviewedNotification = async ( + changeRequest: Change_Request, + submitter: User, + accepted: boolean, + organizationId: string +) => { + const isProd = process.env.NODE_ENV === 'production'; + + const changeRequestLink = isProd + ? `https://finishlinebyner.com/change-requests/${changeRequest.crId}` + : `http://localhost:3000/change-requests/${changeRequest.crId}`; + await NotificationsService.sendNotifcationToUsers( + `CR #${changeRequest.identifier} has been ${accepted ? 'approved!' : 'denied.'}`, + accepted ? 'check_circle' : 'cancel', + [submitter.userId], + organizationId, + changeRequestLink + ); +}; + +/** + * Sends a finishline notification to all requested reviewers of a change request + * @param changeRequest cr that was requested review + * @param reviewers user's reviewing the cr + * @param organizationId id of the organization of the cr + */ +export const sendHomeCrRequestReviewNotification = async ( + changeRequest: Change_Request, + reviewers: User[], + organizationId: string +) => { + const changeRequestLink = `/change-requests/${changeRequest.crId}`; + await NotificationsService.sendNotifcationToUsers( + `Your review has been requested on CR #${changeRequest.identifier}`, + 'edit_note', + reviewers.map((reviewer) => reviewer.userId), + organizationId, + changeRequestLink + ); +}; diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unmocked/users.test.ts index 14d8b0bffe..512a651b90 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -69,4 +69,37 @@ describe('User Tests', () => { expect(notifications[1].text).toBe('test2'); }); }); + + describe('Remove Notifications', () => { + it('Fails with invalid user', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); + const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); + + await expect( + async () => await UsersService.removeUserNotification('1', notifications[0].notificationId, organization) + ).rejects.toThrow(new NotFoundException('User', '1')); + }); + + it('Succeeds and gets user notifications', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); + await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); + + const notifications = await UsersService.getUserUnreadNotifications(testBatman.userId, organization); + + expect(notifications).toHaveLength(2); + expect(notifications[0].text).toBe('test1'); + expect(notifications[1].text).toBe('test2'); + + const updatedNotifications = await UsersService.removeUserNotification( + testBatman.userId, + notifications[0].notificationId, + organization + ); + + expect(updatedNotifications).toHaveLength(1); + expect(updatedNotifications[0].text).toBe('test2'); + }); + }); }); diff --git a/src/frontend/src/apis/users.api.ts b/src/frontend/src/apis/users.api.ts index afa5ea00f6..5a91bff5fd 100644 --- a/src/frontend/src/apis/users.api.ts +++ b/src/frontend/src/apis/users.api.ts @@ -5,6 +5,7 @@ import axios from '../utils/axios'; import { + Notification, Project, SetUserScheduleSettingsPayload, Task, @@ -159,3 +160,19 @@ export const getManyUserTasks = (userIds: string[]) => { } ); }; + +/* + * Gets all unread notifications of the user with the given id + */ +export const getNotifications = (id: string) => { + return axios.get(apiUrls.userNotifications(id), { + transformResponse: (data) => JSON.parse(data) + }); +}; + +/* + * Removes a notification from the user with the given id + */ +export const removeNotification = (userId: string, notificationId: string) => { + return axios.post(apiUrls.userRemoveNotifications(userId), { notificationId }); +}; diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx new file mode 100644 index 0000000000..581d849ef0 --- /dev/null +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -0,0 +1,57 @@ +import { Box } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { Notification, User } from 'shared'; +import NotificationCard from './NotificationCard'; +import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; +import { useHistory } from 'react-router-dom'; + +interface NotificationAlertProps { + user: User; +} + +const NotificationAlert: React.FC = ({ user }) => { + const { data: notifications, isLoading: notificationsIsLoading } = useUserNotifications(user.userId); + const { mutateAsync: removeNotification, isLoading: removeIsLoading } = useRemoveUserNotification(user.userId); + const [currentNotification, setCurrentNotification] = useState(); + const history = useHistory(); + + useEffect(() => { + if (notifications && notifications.length > 0) { + setCurrentNotification(notifications[0]); + } + }, [notifications]); + + const removeNotificationWrapper = async (notification: Notification) => { + setCurrentNotification(undefined); + await removeNotification(notification); + }; + + const onClick = async (notification: Notification) => { + if (!!notification.eventLink) { + await removeNotificationWrapper(notification); + history.push(notification.eventLink); + } + }; + + return ( + + {!removeIsLoading && !notificationsIsLoading && currentNotification && ( + + )} + + ); +}; + +export default NotificationAlert; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx new file mode 100644 index 0000000000..1e4cfb4c02 --- /dev/null +++ b/src/frontend/src/components/NotificationCard.tsx @@ -0,0 +1,73 @@ +import { Box, Card, Icon, IconButton, Typography, useTheme } from '@mui/material'; +import React from 'react'; +import { Notification } from 'shared'; +import CloseIcon from '@mui/icons-material/Close'; + +interface NotificationCardProps { + notification: Notification; + removeNotification: (notificationId: Notification) => Promise; + onClick: (notificationId: Notification) => Promise; +} + +const NotificationCard: React.FC = ({ notification, removeNotification, onClick }) => { + const theme = useTheme(); + return ( + + + await onClick(notification)} + sx={{ + display: 'flex', + gap: 1, + cursor: !!notification.eventLink ? 'pointer' : 'default' + }} + > + + + {notification.iconName} + + + {notification.text} + + removeNotification(notification)}> + + + + + ); +}; + +export default NotificationCard; diff --git a/src/frontend/src/hooks/users.hooks.ts b/src/frontend/src/hooks/users.hooks.ts index 96b659c1f1..32279217f9 100644 --- a/src/frontend/src/hooks/users.hooks.ts +++ b/src/frontend/src/hooks/users.hooks.ts @@ -19,7 +19,9 @@ import { getUserScheduleSettings, updateUserScheduleSettings, getUserTasks, - getManyUserTasks + getManyUserTasks, + getNotifications, + removeNotification } from '../apis/users.api'; import { User, @@ -31,7 +33,8 @@ import { UserScheduleSettings, UserWithScheduleSettings, SetUserScheduleSettingsPayload, - Task + Task, + Notification } from 'shared'; import { useAuth } from './auth.hooks'; import { useContext } from 'react'; @@ -260,3 +263,36 @@ export const useManyUserTasks = (userIds: string[]) => { return data; }); }; + +/** + * Curstom react hook to get all unread notifications from a user + * @param userId id of user to get unread notifications from + * @returns + */ +export const useUserNotifications = (userId: string) => { + return useQuery(['users', userId, 'notifications'], async () => { + const { data } = await getNotifications(userId); + return data; + }); +}; + +/** + * Curstom react hook to remove a notification from a user's unread notifications + * @param userId id of user to get unread notifications from + * @returns + */ +export const useRemoveUserNotification = (userId: string) => { + const queryClient = useQueryClient(); + return useMutation( + ['users', userId, 'notifications', 'remove'], + async (notification: Notification) => { + const { data } = await removeNotification(userId, notification.notificationId); + return data; + }, + { + onSuccess: () => { + queryClient.invalidateQueries(['users', userId, 'notifications']); + } + } + ); +}; diff --git a/src/frontend/src/pages/HomePage/Home.tsx b/src/frontend/src/pages/HomePage/Home.tsx index 961430d92e..76db11f05a 100644 --- a/src/frontend/src/pages/HomePage/Home.tsx +++ b/src/frontend/src/pages/HomePage/Home.tsx @@ -11,20 +11,26 @@ import { useState } from 'react'; import MemberHomePage from './MemberHomePage'; import LeadHomePage from './LeadHomePage'; import AdminHomePage from './AdminHomePage'; +import NotificationAlert from '../../components/NotificationAlert'; const Home = () => { const user = useCurrentUser(); const [onMemberHomePage, setOnMemberHomePage] = useState(false); - return isGuest(user.role) && !onMemberHomePage ? ( - - ) : isMember(user.role) ? ( - - ) : isLead(user.role) ? ( - - ) : isAdmin(user.role) ? ( - - ) : ( - + return ( + <> + {!onMemberHomePage && } + {isGuest(user.role) && !onMemberHomePage ? ( + + ) : isMember(user.role) ? ( + + ) : isLead(user.role) ? ( + + ) : isAdmin(user.role) ? ( + + ) : ( + + )} + ); }; diff --git a/src/frontend/src/utils/urls.ts b/src/frontend/src/utils/urls.ts index 12877900fe..d50675ed69 100644 --- a/src/frontend/src/utils/urls.ts +++ b/src/frontend/src/utils/urls.ts @@ -26,6 +26,8 @@ const userScheduleSettings = (id: string) => `${usersById(id)}/schedule-settings const userScheduleSettingsSet = () => `${users()}/schedule-settings/set`; const userTasks = (id: string) => `${usersById(id)}/tasks`; const manyUserTasks = () => `${users()}/tasks/get-many`; +const userNotifications = (id: string) => `${usersById(id)}/notifications`; +const userRemoveNotifications = (id: string) => `${usersById(id)}/notifications/remove`; /**************** Projects Endpoints ****************/ const projects = () => `${API_URL}/projects`; @@ -212,6 +214,8 @@ export const apiUrls = { userScheduleSettingsSet, userTasks, manyUserTasks, + userNotifications, + userRemoveNotifications, projects, allProjects, diff --git a/src/shared/index.ts b/src/shared/index.ts index 409dae2e65..40246d0fe4 100644 --- a/src/shared/index.ts +++ b/src/shared/index.ts @@ -12,6 +12,7 @@ export * from './src/types/task-types'; export * from './src/types/reimbursement-requests-types'; export * from './src/types/design-review-types'; export * from './src/types/notifications.types'; +export * from './src/types/announcements.types'; export * from './src/validate-wbs'; export * from './src/date-utils'; diff --git a/src/shared/src/types/announcements.types.ts b/src/shared/src/types/announcements.types.ts new file mode 100644 index 0000000000..c0e2d615a7 --- /dev/null +++ b/src/shared/src/types/announcements.types.ts @@ -0,0 +1,12 @@ +import { User } from './user-types'; + +export interface Announcement { + announcementId: string; + text: string; + usersReceived: User[]; + senderName: string; + dateCreated: Date; + slackEventId: string; + slackChannelName: string; + dateDeleted?: Date; +} diff --git a/src/shared/src/types/notifications.types.ts b/src/shared/src/types/notifications.types.ts index e4419ef2ed..abd16fcd21 100644 --- a/src/shared/src/types/notifications.types.ts +++ b/src/shared/src/types/notifications.types.ts @@ -2,4 +2,5 @@ export interface Notification { notificationId: string; text: string; iconName: string; + eventLink?: string; } From 882fcde66323f1fcd75535f4e43b99e9cae5f01e Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Thu, 19 Dec 2024 07:21:43 -0500 Subject: [PATCH 06/17] up to date --- .../announcements.query.args.ts | 11 +++ .../migration.sql | 70 +++++++++++++++ .../src/services/announcement.service.ts | 86 +++++++++++++++++++ .../transformers/announcements.transformer.ts | 19 ++++ .../src/components/NotificationAlert.tsx | 57 ++++++++++++ .../src/components/NotificationCard.tsx | 73 ++++++++++++++++ src/shared/src/types/announcements.types.ts | 12 +++ 7 files changed, 328 insertions(+) create mode 100644 src/backend/src/prisma-query-args/announcements.query.args.ts create mode 100644 src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql create mode 100644 src/backend/src/services/announcement.service.ts create mode 100644 src/backend/src/transformers/announcements.transformer.ts create mode 100644 src/frontend/src/components/NotificationAlert.tsx create mode 100644 src/frontend/src/components/NotificationCard.tsx create mode 100644 src/shared/src/types/announcements.types.ts diff --git a/src/backend/src/prisma-query-args/announcements.query.args.ts b/src/backend/src/prisma-query-args/announcements.query.args.ts new file mode 100644 index 0000000000..b88c9fbf1d --- /dev/null +++ b/src/backend/src/prisma-query-args/announcements.query.args.ts @@ -0,0 +1,11 @@ +import { Prisma } from '@prisma/client'; +import { getUserQueryArgs } from './user.query-args'; + +export type AnnouncementQueryArgs = ReturnType; + +export const getAnnouncementQueryArgs = (organizationId: string) => + Prisma.validator()({ + include: { + usersReceived: getUserQueryArgs(organizationId) + } + }); diff --git a/src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql b/src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql new file mode 100644 index 0000000000..37c468e58e --- /dev/null +++ b/src/backend/src/prisma/migrations/20241218160143_homepage_updates/migration.sql @@ -0,0 +1,70 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT; + +-- AlterTable +ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; + +-- CreateTable +CREATE TABLE "Announcement" ( + "announcementId" TEXT NOT NULL, + "text" TEXT NOT NULL, + "dateCreated" TIMESTAMP(3) NOT NULL, + "dateDeleted" TIMESTAMP(3), + "senderName" TEXT NOT NULL, + "slackEventId" TEXT NOT NULL, + "slackChannelName" TEXT NOT NULL, + + CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") +); + +-- CreateTable +CREATE TABLE "Notification" ( + "notificationId" TEXT NOT NULL, + "text" TEXT NOT NULL, + "iconName" TEXT NOT NULL, + "eventLink" TEXT, + + CONSTRAINT "Notification_pkey" PRIMARY KEY ("notificationId") +); + +-- CreateTable +CREATE TABLE "_receivedAnnouncements" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateTable +CREATE TABLE "_userNotifications" ( + "A" TEXT NOT NULL, + "B" TEXT NOT NULL +); + +-- CreateIndex +CREATE UNIQUE INDEX "Announcement_slackEventId_key" ON "Announcement"("slackEventId"); + +-- CreateIndex +CREATE UNIQUE INDEX "_receivedAnnouncements_AB_unique" ON "_receivedAnnouncements"("A", "B"); + +-- CreateIndex +CREATE INDEX "_receivedAnnouncements_B_index" ON "_receivedAnnouncements"("B"); + +-- CreateIndex +CREATE UNIQUE INDEX "_userNotifications_AB_unique" ON "_userNotifications"("A", "B"); + +-- CreateIndex +CREATE INDEX "_userNotifications_B_index" ON "_userNotifications"("B"); + +-- AddForeignKey +ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_A_fkey" FOREIGN KEY ("A") REFERENCES "Announcement"("announcementId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_userNotifications" ADD CONSTRAINT "_userNotifications_A_fkey" FOREIGN KEY ("A") REFERENCES "Notification"("notificationId") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "_userNotifications" ADD CONSTRAINT "_userNotifications_B_fkey" FOREIGN KEY ("B") REFERENCES "User"("userId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts new file mode 100644 index 0000000000..c4199a40a8 --- /dev/null +++ b/src/backend/src/services/announcement.service.ts @@ -0,0 +1,86 @@ +import { Announcement } from 'shared'; +import prisma from '../prisma/prisma'; +import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import announcementTransformer from '../transformers/announcements.transformer'; +import { NotFoundException } from '../utils/errors.utils'; + +export default class AnnouncementService { + static async createAnnouncement( + text: string, + usersReceivedIds: string[], + dateCreated: Date, + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const announcement = await prisma.announcement.create({ + data: { + text, + usersReceived: { + connect: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + dateCreated, + senderName, + slackEventId, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async UpdateAnnouncement( + text: string, + usersReceivedIds: string[], + dateCreated: Date, + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + + const announcement = await prisma.announcement.update({ + where: { announcementId: originalAnnouncement.announcementId }, + data: { + text, + usersReceived: { + connect: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + slackEventId, + dateCreated, + senderName, + slackChannelName + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async DeleteAnnouncement(slackEventId: string, organizationId: string): Promise { + const announcement = await prisma.announcement.update({ + where: { slackEventId }, + data: { + dateDeleted: new Date() + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + if (!announcement) throw new NotFoundException('Announcement', slackEventId); + + return announcementTransformer(announcement); + } +} diff --git a/src/backend/src/transformers/announcements.transformer.ts b/src/backend/src/transformers/announcements.transformer.ts new file mode 100644 index 0000000000..4fee90eac2 --- /dev/null +++ b/src/backend/src/transformers/announcements.transformer.ts @@ -0,0 +1,19 @@ +import { Prisma } from '@prisma/client'; +import { AnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; +import { Announcement } from 'shared'; +import { userTransformer } from './user.transformer'; + +const announcementTransformer = (announcement: Prisma.AnnouncementGetPayload): Announcement => { + return { + announcementId: announcement.announcementId, + text: announcement.text, + usersReceived: announcement.usersReceived.map(userTransformer), + dateCreated: announcement.dateCreated, + senderName: announcement.senderName, + slackEventId: announcement.slackEventId, + slackChannelName: announcement.slackChannelName, + dateDeleted: announcement.dateDeleted ?? undefined + }; +}; + +export default announcementTransformer; diff --git a/src/frontend/src/components/NotificationAlert.tsx b/src/frontend/src/components/NotificationAlert.tsx new file mode 100644 index 0000000000..581d849ef0 --- /dev/null +++ b/src/frontend/src/components/NotificationAlert.tsx @@ -0,0 +1,57 @@ +import { Box } from '@mui/material'; +import React, { useEffect, useState } from 'react'; +import { Notification, User } from 'shared'; +import NotificationCard from './NotificationCard'; +import { useRemoveUserNotification, useUserNotifications } from '../hooks/users.hooks'; +import { useHistory } from 'react-router-dom'; + +interface NotificationAlertProps { + user: User; +} + +const NotificationAlert: React.FC = ({ user }) => { + const { data: notifications, isLoading: notificationsIsLoading } = useUserNotifications(user.userId); + const { mutateAsync: removeNotification, isLoading: removeIsLoading } = useRemoveUserNotification(user.userId); + const [currentNotification, setCurrentNotification] = useState(); + const history = useHistory(); + + useEffect(() => { + if (notifications && notifications.length > 0) { + setCurrentNotification(notifications[0]); + } + }, [notifications]); + + const removeNotificationWrapper = async (notification: Notification) => { + setCurrentNotification(undefined); + await removeNotification(notification); + }; + + const onClick = async (notification: Notification) => { + if (!!notification.eventLink) { + await removeNotificationWrapper(notification); + history.push(notification.eventLink); + } + }; + + return ( + + {!removeIsLoading && !notificationsIsLoading && currentNotification && ( + + )} + + ); +}; + +export default NotificationAlert; diff --git a/src/frontend/src/components/NotificationCard.tsx b/src/frontend/src/components/NotificationCard.tsx new file mode 100644 index 0000000000..1e4cfb4c02 --- /dev/null +++ b/src/frontend/src/components/NotificationCard.tsx @@ -0,0 +1,73 @@ +import { Box, Card, Icon, IconButton, Typography, useTheme } from '@mui/material'; +import React from 'react'; +import { Notification } from 'shared'; +import CloseIcon from '@mui/icons-material/Close'; + +interface NotificationCardProps { + notification: Notification; + removeNotification: (notificationId: Notification) => Promise; + onClick: (notificationId: Notification) => Promise; +} + +const NotificationCard: React.FC = ({ notification, removeNotification, onClick }) => { + const theme = useTheme(); + return ( + + + await onClick(notification)} + sx={{ + display: 'flex', + gap: 1, + cursor: !!notification.eventLink ? 'pointer' : 'default' + }} + > + + + {notification.iconName} + + + {notification.text} + + removeNotification(notification)}> + + + + + ); +}; + +export default NotificationCard; diff --git a/src/shared/src/types/announcements.types.ts b/src/shared/src/types/announcements.types.ts new file mode 100644 index 0000000000..c0e2d615a7 --- /dev/null +++ b/src/shared/src/types/announcements.types.ts @@ -0,0 +1,12 @@ +import { User } from './user-types'; + +export interface Announcement { + announcementId: string; + text: string; + usersReceived: User[]; + senderName: string; + dateCreated: Date; + slackEventId: string; + slackChannelName: string; + dateDeleted?: Date; +} From e80337e30c0323633d4fe4cca1ace30fcf39565e Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Fri, 20 Dec 2024 15:28:51 -0500 Subject: [PATCH 07/17] #2823 slack service comments and tests --- .../src/controllers/slack.controllers.ts | 42 -- .../src/services/announcement.service.ts | 4 +- src/backend/src/services/slack.services.ts | 109 +++- .../{unmocked => mocked}/organization.test.ts | 0 .../tests/mocked/slackMessages.test.ts | 464 ++++++++++++++++++ src/backend/tests/test-utils.ts | 32 ++ 6 files changed, 591 insertions(+), 60 deletions(-) delete mode 100644 src/backend/src/controllers/slack.controllers.ts rename src/backend/tests/{unmocked => mocked}/organization.test.ts (100%) create mode 100644 src/backend/tests/mocked/slackMessages.test.ts diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts deleted file mode 100644 index e3c042291b..0000000000 --- a/src/backend/src/controllers/slack.controllers.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { Request, Response } from 'express'; -import crypto from 'crypto'; -import slackServices from '../services/slack.services'; - -export default class slackController { - static async handleEvent(req: Request, res: Response) { - console.log('got a slack req'); - if (req.body.type === 'url_verification') { - return res.status(200).send({ challenge: req.body.challenge }); - } - - const slackSignature = req.headers['x-slack-signature'] as string; - const slackTimeStamp = req.headers['X-Slack-Request-Timestamp'] as string; - - if (Math.abs(Date.now() - Number(slackTimeStamp) * 1000) > 60 * 5 * 1000) { - return res.status(400).send('Slack request verification failed due to expired timestamp'); - } - - const reqBody = req.body; - - const signatureBase = 'v0:' + slackTimeStamp + ':' + reqBody; - - const finalSignature = - 'v0=' + - crypto - .createHmac('sha256', process.env.SLACK_BOT_TOKEN ? process.env.SLACK_BOT_TOKEN : '') - .update(signatureBase) - .digest('hex'); - - if ( - crypto.timingSafeEqual( - Uint8Array.from(Buffer.from(finalSignature, 'utf8')), - Uint8Array.from(Buffer.from(slackSignature, 'utf8')) - ) - ) { - slackServices.processEvent(req.body); - return res.status(200).send('Event recieved'); - } - - return res.status(400).send('Slack request verification failed due to incorrect signature'); - } -} diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index c4199a40a8..7be31175f0 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -33,7 +33,7 @@ export default class AnnouncementService { return announcementTransformer(announcement); } - static async UpdateAnnouncement( + static async updateAnnouncement( text: string, usersReceivedIds: string[], dateCreated: Date, @@ -70,7 +70,7 @@ export default class AnnouncementService { return announcementTransformer(announcement); } - static async DeleteAnnouncement(slackEventId: string, organizationId: string): Promise { + static async deleteAnnouncement(slackEventId: string, organizationId: string): Promise { const announcement = await prisma.announcement.update({ where: { slackEventId }, data: { diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index d9d028adbf..991af8fc18 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -4,17 +4,23 @@ import { User_Settings } from '@prisma/client'; import AnnouncementService from './announcement.service'; import { Announcement } from 'shared'; +/** + * Represents a slack event for a message in a channel. + */ export interface SlackMessageEvent { type: 'message'; subtype?: string; channel: string; event_ts: string; channel_type: string; + [key: string]: any; } +/** + * Represents a slack message event for a standard sent message. + */ export interface SlackMessage extends SlackMessageEvent { user: string; - type: 'message'; client_msg_id: string; text: string; blocks: { @@ -24,17 +30,27 @@ export interface SlackMessage extends SlackMessageEvent { }[]; } +/** + * Represents a slack message event for a deleted message. + */ export interface SlackDeletedMessage extends SlackMessageEvent { subtype: 'message_deleted'; previous_message: SlackMessage; } +/** + * Represents a slack message event for an edited message. + */ export interface SlackUpdatedMessage extends SlackMessageEvent { subtype: 'message_changed'; message: SlackMessage; previous_message: SlackMessage; } +/** + * Represents a block of information within a message. These blocks with an array + * make up all the information needed to represent the content of a message. + */ export interface SlackRichTextBlock { type: 'broadcast' | 'color' | 'channel' | 'date' | 'emoji' | 'link' | 'text' | 'user' | 'usergroup'; range?: string; @@ -50,6 +66,11 @@ export interface SlackRichTextBlock { } export default class slackServices { + /** + * Converts a SlackRichTextBlock into a string representation for an announcement. + * @param block the block of information from slack + * @returns the string that will be combined with other block's strings to create the announcement + */ private static async blockToString(block: SlackRichTextBlock): Promise { switch (block.type) { case 'broadcast': @@ -57,6 +78,7 @@ export default class slackServices { case 'color': return block.value ?? ''; case 'channel': + //channels are represented as an id, get the name from the slack api let channelName = block.channel_id; try { channelName = await getChannelName(block.channel_id ?? ''); @@ -67,8 +89,10 @@ export default class slackServices { case 'date': return new Date(block.timestamp ?? 0).toISOString(); case 'emoji': + //if the emoji is a unicode emoji, convert the unicode to a string, + //if it is a slack emoji just use the name of the emoji if (block.unicode) { - return String.fromCharCode(parseInt(block.unicode, 16)); + return String.fromCodePoint(parseInt(block.unicode, 16)); } return 'emoji:' + block.name; case 'link': @@ -79,6 +103,7 @@ export default class slackServices { case 'text': return block.text ?? ''; case 'user': + //users are represented as an id, get the name of the user from the slack api let userName: string = block.user_id ?? ''; try { userName = (await getUserName(block.user_id ?? '')) ?? `Unknown User:${block.user_id}`; @@ -91,6 +116,13 @@ export default class slackServices { } } + /** + * Gets the users notified in a specific SlackRichTextBlock. + * @param block the block that may contain mentioned user/users + * @param usersSettings the settings of all the users in prisma + * @param channelId the id of the channel that the block is being sent in + * @returns an array of prisma user ids of users to be notified + */ private static async blockToMentionedUsers( block: SlackRichTextBlock, usersSettings: User_Settings[], @@ -103,7 +135,11 @@ export default class slackServices { return usersSettings.map((usersSettings) => usersSettings.userId); case 'channel': case 'here': - const slackIds = await getUsersInChannel(channelId); + //@here behaves the same as @channel; notifies all the users in that channel + let slackIds: string[] = []; + try { + slackIds = await getUsersInChannel(channelId); + } catch (ignored) {} return usersSettings .filter((userSettings) => { return slackIds.some((slackId) => slackId === userSettings.slackId); @@ -117,22 +153,38 @@ export default class slackServices { .filter((userSettings) => userSettings.slackId === block.user_id) .map((userSettings) => userSettings.userId); default: + //only broadcasts and specific user mentions add recievers to announcements return []; } } + /** + * Given a slack event representing a message in a channel, + * make the appropriate announcement change in prisma. + * @param event the slack event that will be processed + * @param organizationId the id of the organization represented by the slack api + * @returns an annoucement if an announcement was processed and created/modified/deleted + */ static async processMessageSent(event: SlackMessageEvent, organizationId: string): Promise { - const slackChannelName = await getChannelName(event.channel); + let slackChannelName: string; + //get the name of the channel from the slack api + try { + slackChannelName = await getChannelName(event.channel); + } catch (error) { + slackChannelName = `Unknown_Channel:${event.channel}`; + } const dateCreated = new Date(Number(event.event_ts)); + //get the message that will be processed either as the event or within a subtype let eventMessage: SlackMessage; if (event.subtype) { switch (event.subtype) { case 'message_deleted': + //delete the message using the client_msg_id eventMessage = (event as SlackDeletedMessage).previous_message; try { - return AnnouncementService.DeleteAnnouncement(eventMessage.client_msg_id, organizationId); + return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); } catch (ignored) { return; } @@ -147,9 +199,12 @@ export default class slackServices { eventMessage = event as SlackMessage; } + //loop through the blocks of the meta data while accumulating the + //text and users notified let messageText = ''; let userIdsToNotify: string[] = []; + //Get the settings of all users in this organization to compare slack ids const users = await UsersService.getAllUsers(); const userSettings = await Promise.all( users.map((user) => { @@ -157,22 +212,28 @@ export default class slackServices { }) ); + //get the name of the user that sent the message from slack let userName: string = ''; try { userName = (await getUserName(eventMessage.user)) ?? ''; } catch (ignored) {} + //if slack could not produce the name of the user, look for their name in prisma if (!userName) { const userIdList = userSettings .filter((userSetting) => userSetting.slackId === eventMessage.user) .map((userSettings) => userSettings.userId); if (userIdList.length !== 0) { - userName = users.find((user) => user.userId === userIdList[0])?.firstName ?? 'Unknown User:' + eventMessage.user; + const prismaUserName = users.find((user) => user.userId === userIdList[0]); + userName = prismaUserName + ? `${prismaUserName?.firstName} ${prismaUserName?.lastName}` + : 'Unknown User:' + eventMessage.user; } else { userName = 'Unknown_User:' + eventMessage.user; } } + //pull out the blocks of data from the metadata within the message event const richTextBlocks = eventMessage.blocks?.filter((eventBlock: any) => eventBlock.type === 'rich_text'); if (richTextBlocks && richTextBlocks.length === 1) { @@ -186,9 +247,20 @@ export default class slackServices { return; } + //get rid of duplicates within the users to notify + userIdsToNotify = [...new Set(userIdsToNotify)]; + + //if no users are notified, disregard the message + if (userIdsToNotify.length === 0) { + return; + } + + console.log('processed event'); + if (event.subtype === 'message_changed') { + //try to edit the announcement, if no announcement with that id exists create a new announcement try { - return AnnouncementService.UpdateAnnouncement( + return await AnnouncementService.updateAnnouncement( messageText, userIdsToNotify, dateCreated, @@ -199,14 +271,19 @@ export default class slackServices { ); } catch (ignored) {} } - return AnnouncementService.createAnnouncement( - messageText, - userIdsToNotify, - dateCreated, - userName, - eventMessage.client_msg_id, - slackChannelName, - organizationId - ); + try { + return await AnnouncementService.createAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } catch (error) { + //if announcement does not have unique cient_msg_id disregard it + return; + } } } diff --git a/src/backend/tests/unmocked/organization.test.ts b/src/backend/tests/mocked/organization.test.ts similarity index 100% rename from src/backend/tests/unmocked/organization.test.ts rename to src/backend/tests/mocked/organization.test.ts diff --git a/src/backend/tests/mocked/slackMessages.test.ts b/src/backend/tests/mocked/slackMessages.test.ts new file mode 100644 index 0000000000..f8fc76f4ab --- /dev/null +++ b/src/backend/tests/mocked/slackMessages.test.ts @@ -0,0 +1,464 @@ +import { Organization, User } from '@prisma/client'; +import { createSlackMessageEvent, createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { + batmanAppAdmin, + batmanSettings, + supermanAdmin, + supermanSettings, + wonderwomanGuest, + wonderwomanSettings +} from '../test-data/users.test-data'; +import * as apiFunctions from '../../src/integrations/slack'; +import AnnouncementService from '../../src/services/announcement.service'; +import slackServices from '../../src/services/slack.services'; +import { vi } from 'vitest'; +import { HttpException } from '../../src/utils/errors.utils'; + +vi.mock('../../src/integrations/slack', async (importOriginal) => { + return { + ...(await importOriginal()), + getUserName: vi.fn(), + getChannelName: vi.fn(), + getUsersInChannel: vi.fn() + }; +}); + +describe('Slack message tests', () => { + let orgId: string; + let organization: Organization; + let batman: User; + let superman: User; + let wonderwoman: User; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); + superman = await createTestUser(supermanAdmin, orgId, supermanSettings); + wonderwoman = await createTestUser(wonderwomanGuest, orgId, wonderwomanSettings); + }); + + afterEach(async () => { + await resetUsers(); + vi.clearAllMocks(); + }); + + it('adds message to everyone with @everyone', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'everyone' }, + { type: 'text', text: ' broadcast (@everyone)' } + ]), + orgId + ); + + console.log(announcement); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @everyone broadcast (@everyone)', + [organization.userCreatedId, batman.userId, superman.userId, wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @everyone broadcast (@everyone)'); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(4); + }); + + it('Adds message to people in channel with @channel and @mention (w/o duplicates)', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + vi.mocked(apiFunctions.getUsersInChannel).mockReturnValue(Promise.resolve(['slack', 'slackWW'])); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'channel' }, + { type: 'text', text: ' broadcast (@channel)' }, + { type: 'user', user_id: 'slackWW' }, + { type: 'user', user_id: 'slackSM' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @channel broadcast (@channel)@Slack User Name@Slack User Name', + [batman.userId, wonderwoman.userId, superman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @channel broadcast (@channel)@Slack User Name@Slack User Name'); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(3); + }); + + it('Sends the announcement to a single person with a mention', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' broadcast (@wonderwoman)' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @Slack User Name broadcast (@wonderwoman)', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @Slack User Name broadcast (@wonderwoman)'); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it('Correctly processes other types of blocks', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'test with: ' }, + { type: 'link', url: 'http://www.example.com', text: 'link' }, + { type: 'text', text: 'Italics', style: { italic: true } }, + { type: 'text', text: ' and a unicode emoji: ' }, + { + type: 'emoji', + name: 'stuck_out_tongue_closed_eyes', + unicode: '1f61d' + }, + { type: 'text', text: ' and a slack emoji: ' }, + { + type: 'emoji', + name: 'birthday-parrot' + }, + { type: 'user', user_id: 'slackWW' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe( + 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name' + ); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Slack User Name'); + expect(announcement?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it('Deals with errors from slack API', async () => { + vi.mocked(apiFunctions.getUserName).mockImplementation(() => { + throw new HttpException(500, 'sample error'); + }); + vi.mocked(apiFunctions.getChannelName).mockImplementation(() => { + throw new HttpException(500, 'sample error'); + }); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'slackWW', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' prisma user and non-prisma user ' }, + { type: 'user', user_id: 'non-prisma-slack-id' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + '@Unknown_User:slackWW prisma user and non-prisma user @Unknown_User:non-prisma-slack-id', + [wonderwoman.userId], + new Date(1000000000000), + 'Wonder Woman', + 'id_1', + 'Unknown_Channel:channel id', + orgId + ); + + expect(announcement?.text).toBe( + '@Unknown_User:slackWW prisma user and non-prisma user @Unknown_User:non-prisma-slack-id' + ); + expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.senderName).toBe('Wonder Woman'); + expect(announcement?.slackChannelName).toBe('Unknown_Channel:channel id'); + expect(announcement?.slackEventId).toBe('id_1'); + expect(announcement?.usersReceived).toHaveLength(1); + }); + + it("Doesn't create an announcement if no one is mentioned", async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'text', text: 'just a text message' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(0); + + expect(announcement).toBeUndefined(); + }); + + it('Does nothing if an announcement with the same slack id has already been created', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + const announcement2 = await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + orgId + ); + expect(announcement2).toBeUndefined(); + }); + + it('Updates an edit made to a message', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + expect(announcement2?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement2?.senderName).toBe('Slack User Name'); + expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement2?.slackEventId).toBe('id_1'); + expect(announcement2?.usersReceived).toHaveLength(1); + + expect(createSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + + it('Creates a new announcement if the announcement to update is not found', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(createSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + expect(announcement2?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement2?.senderName).toBe('Slack User Name'); + expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); + expect(announcement2?.slackEventId).toBe('id_1'); + expect(announcement2?.usersReceived).toHaveLength(1); + + expect(createSpy).toBeCalledTimes(1); + expect(updateSpy).toBeCalledTimes(1); + }); + + it('Creates and deletes and announcement', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + const deleteSpy = vi.spyOn(AnnouncementService, 'deleteAnnouncement'); + + await slackServices.processMessageSent( + createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000000000000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_deleted', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + expect(createSpy).toBeCalledTimes(1); + expect(deleteSpy).toBeCalledTimes(1); + expect(deleteSpy).toBeCalledWith('id_1', orgId); + }); + + it('Does nothing if recieves other message subtype', async () => { + vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); + vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); + + const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); + + const announcement = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'other-nonprocessed-subtype', + channel: 'channel id', + event_ts: '1000000000000', + channel_type: 'channel', + bogus_data: 'other data' + }, + orgId + ); + expect(createSpy).toBeCalledTimes(0); + expect(announcement).toBeUndefined(); + }); +}); diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 99f2a010e2..fc6698ff06 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -18,6 +18,7 @@ import { getWorkPackageTemplateQueryArgs } from '../src/prisma-query-args/work-p import DesignReviewsService from '../src/services/design-reviews.services'; import TasksService from '../src/services/tasks.services'; import ProjectsService from '../src/services/projects.services'; +import { SlackMessage } from '../src/services/slack.services'; export interface CreateTestUserParams { firstName: string; @@ -119,6 +120,7 @@ export const resetUsers = async () => { await prisma.milestone.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.organization.deleteMany(); + await prisma.announcement.deleteMany(); await prisma.user.deleteMany(); }; @@ -454,3 +456,33 @@ export const createTestTask = async (user: User, organization?: Organization) => if (!task) throw new Error('Failed to create task'); return { task, organization, orgId }; }; + +export const createSlackMessageEvent = ( + channel: string, + event_ts: string, + user: string, + client_msg_id: string, + elements: any[] +): SlackMessage => { + return { + type: 'message', + channel, + event_ts, + channel_type: 'channel', + user, + client_msg_id, + text: 'sample text', + blocks: [ + { + type: 'rich_text', + block_id: 'block id', + elements: [ + { + type: 'rich_text_section', + elements + } + ] + } + ] + }; +}; From 811b608798bb0321b705a5a2e24d4eb57f858d5b Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Fri, 20 Dec 2024 16:23:00 -0500 Subject: [PATCH 08/17] #2823 slack id to organization schema change, service funcs, and slack router addition --- src/backend/src/integrations/slack.ts | 19 +++++++++++ .../migration.sql | 3 +- src/backend/src/prisma/schema.prisma | 1 + src/backend/src/routes/slack.routes.ts | 10 ++++-- .../src/services/organizations.services.ts | 34 +++++++++++++++++++ src/backend/tests/mocked/organization.test.ts | 32 +++++++++++++++++ 6 files changed, 96 insertions(+), 3 deletions(-) rename src/backend/src/prisma/migrations/{20241220204035_homepage_updates => 20241220204512_home_page_updates}/migration.sql (95%) diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index d2440e6288..7c80fa819a 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -220,4 +220,23 @@ export const getUserName = async (userId: string) => { } }; +/** + * Get the workspace id of the workspace this slack api is registered with + * @returns the id of the workspace + */ +export const getWorkspaceId = async () => { + const { SLACK_BOT_TOKEN } = process.env; + if (!SLACK_BOT_TOKEN) return; + + try { + const response = await slack.auth.test(); + if (response.ok) { + return response.team_id; + } + throw new Error(response.error); + } catch (error) { + throw new HttpException(500, 'Error getting slack workspace id: ' + (error as any).data.error); + } +}; + export default slack; diff --git a/src/backend/src/prisma/migrations/20241220204035_homepage_updates/migration.sql b/src/backend/src/prisma/migrations/20241220204512_home_page_updates/migration.sql similarity index 95% rename from src/backend/src/prisma/migrations/20241220204035_homepage_updates/migration.sql rename to src/backend/src/prisma/migrations/20241220204512_home_page_updates/migration.sql index 37c468e58e..e20892547e 100644 --- a/src/backend/src/prisma/migrations/20241220204035_homepage_updates/migration.sql +++ b/src/backend/src/prisma/migrations/20241220204512_home_page_updates/migration.sql @@ -1,5 +1,6 @@ -- AlterTable -ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT; +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT, +ADD COLUMN "slackWorkspaceId" TEXT; -- AlterTable ALTER TABLE "Project" ADD COLUMN "organizationId" TEXT; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index bb8247266d..3068c9558f 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -877,6 +877,7 @@ model Organization { applyInterestImageId String? @unique exploreAsGuestImageId String? @unique logoImageId String? + slackWorkspaceId String? // Relation references wbsElements WBS_Element[] diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index a938ac0691..82b98b0a0b 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -1,11 +1,17 @@ import { createEventAdapter } from '@slack/events-api'; import slackServices from '../services/slack.services'; +import OrganizationsService from '../services/organizations.services'; +import { getWorkspaceId } from '../integrations/slack'; export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); slackEvents.on('message', async (event) => { - console.log('EVENT:', event); - slackServices.processMessageSent(event, process.env.DEV_ORGANIZATION_ID ?? ''); + const organizations = await OrganizationsService.getAllOrganizations(); + const nerSlackWorkspaceId = await getWorkspaceId(); + const orgId = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId)?.organizationId; + if (orgId) { + slackServices.processMessageSent(event, orgId); + } }); slackEvents.on('error', (error) => { diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index c4ce91668b..acd6f24865 100644 --- a/src/backend/src/services/organizations.services.ts +++ b/src/backend/src/services/organizations.services.ts @@ -12,6 +12,14 @@ import { getProjectQueryArgs } from '../prisma-query-args/projects.query-args'; import projectTransformer from '../transformers/projects.transformer'; export default class OrganizationsService { + /** + * Retrieve all the organizations + * @returns an array of every organization + */ + static async getAllOrganizations(): Promise { + return prisma.organization.findMany(); + } + /** * Gets the current organization * @param organizationId the organizationId to be fetched @@ -275,4 +283,30 @@ export default class OrganizationsService { return organization.featuredProjects.map(projectTransformer); } + + /** + * Sets the slack workspace id used to initialize slack bots for this organization + * @param slackWorkspaceId the id of the organization's slack workspace + * @param submitter the user making this submission (must be an admin) + * @param organization the organization being changed + * @returns the changed organization + */ + static async setOrganizationSlackWorkspaceId( + slackWorkspaceId: string, + submitter: User, + organization: Organization + ): Promise { + if (!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))) { + throw new AccessDeniedAdminOnlyException('set slack workspace id'); + } + const updatedOrg = prisma.organization.update({ + where: { + organizationId: organization.organizationId + }, + data: { + slackWorkspaceId + } + }); + return updatedOrg; + } } diff --git a/src/backend/tests/mocked/organization.test.ts b/src/backend/tests/mocked/organization.test.ts index 2c0affdff9..295b96031d 100644 --- a/src/backend/tests/mocked/organization.test.ts +++ b/src/backend/tests/mocked/organization.test.ts @@ -325,4 +325,36 @@ describe('Organization Tests', () => { expect(oldOrganization?.description).toBe(returnedOrganization.description); }); }); + + describe('Set Organization slack id', () => { + it('Fails if user is not an admin', async () => { + await expect( + OrganizationsService.setOrganizationSlackWorkspaceId( + 'test slack id', + await createTestUser(wonderwomanGuest, orgId), + organization + ) + ).rejects.toThrow(new AccessDeniedAdminOnlyException('set slack workspace id')); + }); + + it('Succeeds and updates the slack id', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + + const returnedOrganization = await OrganizationsService.setOrganizationSlackWorkspaceId( + 'sample slack id', + testBatman, + organization + ); + + const oldOrganization = await prisma.organization.findUnique({ + where: { + organizationId: orgId + } + }); + + expect(oldOrganization).not.toBeNull(); + expect(oldOrganization?.slackWorkspaceId).toBe('sample slack id'); + expect(oldOrganization?.slackWorkspaceId).toBe(returnedOrganization.slackWorkspaceId); + }); + }); }); From 147b5ddca972b079d746bc53bf2f18f0a6c3efa4 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Fri, 20 Dec 2024 16:55:52 -0500 Subject: [PATCH 09/17] #2823 announcement service function tests --- .../src/services/announcement.service.ts | 17 +- .../tests/unmocked/announcements.test.ts | 158 ++++++++++++++++++ 2 files changed, 171 insertions(+), 4 deletions(-) create mode 100644 src/backend/tests/unmocked/announcements.test.ts diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index 342d5de296..9ff1e7007e 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -67,7 +67,7 @@ export default class AnnouncementService { data: { text, usersReceived: { - connect: usersReceivedIds.map((id) => ({ + set: usersReceivedIds.map((id) => ({ userId: id })) }, @@ -83,16 +83,25 @@ export default class AnnouncementService { } static async deleteAnnouncement(slackEventId: string, organizationId: string): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + const announcement = await prisma.announcement.update({ where: { slackEventId }, data: { - dateDeleted: new Date() + dateDeleted: new Date(), + usersReceived: { + set: [] + } }, ...getAnnouncementQueryArgs(organizationId) }); - if (!announcement) throw new NotFoundException('Announcement', slackEventId); - return announcementTransformer(announcement); } } diff --git a/src/backend/tests/unmocked/announcements.test.ts b/src/backend/tests/unmocked/announcements.test.ts new file mode 100644 index 0000000000..7bd6781a1d --- /dev/null +++ b/src/backend/tests/unmocked/announcements.test.ts @@ -0,0 +1,158 @@ +import { Organization, User } from '@prisma/client'; +import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; +import { + batmanAppAdmin, + batmanSettings, + supermanAdmin, + supermanSettings, + wonderwomanGuest, + wonderwomanSettings +} from '../test-data/users.test-data'; +import AnnouncementService from '../../src/services/announcement.service'; +import UsersService from '../../src/services/users.services'; +import { NotFoundException } from '../../src/utils/errors.utils'; + +describe('announcement tests', () => { + let orgId: string; + let organization: Organization; + let batman: User; + let superman: User; + let wonderwoman: User; + + beforeEach(async () => { + organization = await createTestOrganization(); + orgId = organization.organizationId; + batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); + superman = await createTestUser(supermanAdmin, orgId, supermanSettings); + wonderwoman = await createTestUser(wonderwomanGuest, orgId, wonderwomanSettings); + }); + + afterEach(async () => { + await resetUsers(); + }); + + it('creates announcements which can be recieved via users', async () => { + const announcement = await AnnouncementService.createAnnouncement( + 'text', + [superman.userId, batman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + expect(announcement?.text).toBe('text'); + expect(announcement?.usersReceived).toHaveLength(2); + expect(announcement?.senderName).toBe('sender name'); + expect(announcement?.dateCreated).toStrictEqual(new Date(1000000000000)); + expect(announcement?.slackEventId).toBe('slack id'); + expect(announcement?.slackChannelName).toBe('channel name'); + expect(announcement?.dateDeleted).toBeUndefined(); + + const smAnnouncements = await UsersService.getUserUnreadAnnouncements(superman.userId, organization); + const bmAnnouncements = await UsersService.getUserUnreadAnnouncements(batman.userId, organization); + const wwAnnouncements = await UsersService.getUserUnreadAnnouncements(wonderwoman.userId, organization); + + expect(smAnnouncements).toHaveLength(1); + expect(smAnnouncements[0]?.text).toBe('text'); + expect(smAnnouncements[0]?.usersReceived).toHaveLength(2); + expect(smAnnouncements[0]?.senderName).toBe('sender name'); + expect(smAnnouncements[0]?.dateCreated).toStrictEqual(new Date(1000000000000)); + expect(smAnnouncements[0]?.slackEventId).toBe('slack id'); + expect(smAnnouncements[0]?.slackChannelName).toBe('channel name'); + expect(smAnnouncements[0]?.dateDeleted).toBeUndefined(); + + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(0); + }); + + it('updates an announcement', async () => { + await AnnouncementService.createAnnouncement( + 'text', + [superman.userId, batman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + let smAnnouncements = await UsersService.getUserUnreadAnnouncements(superman.userId, organization); + let bmAnnouncements = await UsersService.getUserUnreadAnnouncements(batman.userId, organization); + let wwAnnouncements = await UsersService.getUserUnreadAnnouncements(wonderwoman.userId, organization); + + expect(smAnnouncements).toHaveLength(1); + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(0); + + const updatedAnnouncement = await AnnouncementService.updateAnnouncement( + 'new text', + [batman.userId, wonderwoman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + smAnnouncements = await UsersService.getUserUnreadAnnouncements(superman.userId, organization); + bmAnnouncements = await UsersService.getUserUnreadAnnouncements(batman.userId, organization); + wwAnnouncements = await UsersService.getUserUnreadAnnouncements(wonderwoman.userId, organization); + + expect(smAnnouncements).toHaveLength(0); + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(1); + expect(bmAnnouncements[0]?.text).toBe('new text'); + expect(wwAnnouncements[0]?.text).toBe('new text'); + expect(updatedAnnouncement?.text).toBe('new text'); + }); + + it('fails to update if there is no slack id', async () => { + await expect( + async () => + await AnnouncementService.updateAnnouncement( + 'new text', + [batman.userId, wonderwoman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ) + ).rejects.toThrow(new NotFoundException('Announcement', 'slack id')); + }); + + it('deletes an announcement', async () => { + await AnnouncementService.createAnnouncement( + 'text', + [superman.userId, batman.userId], + new Date(1000000000000), + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + let smAnnouncements = await UsersService.getUserUnreadAnnouncements(superman.userId, organization); + let bmAnnouncements = await UsersService.getUserUnreadAnnouncements(batman.userId, organization); + + expect(smAnnouncements).toHaveLength(1); + expect(bmAnnouncements).toHaveLength(1); + + const deletedAnnouncement = await AnnouncementService.deleteAnnouncement('slack id', orgId); + + smAnnouncements = await UsersService.getUserUnreadAnnouncements(superman.userId, organization); + bmAnnouncements = await UsersService.getUserUnreadAnnouncements(batman.userId, organization); + + expect(smAnnouncements).toHaveLength(0); + expect(bmAnnouncements).toHaveLength(0); + expect(deletedAnnouncement?.text).toBe('text'); + expect(deletedAnnouncement?.dateDeleted).toBeDefined(); + }); + + it('throws if it cannot find the announcement to delete', async () => { + await expect(async () => await AnnouncementService.deleteAnnouncement('non-existent id', orgId)).rejects.toThrow( + new NotFoundException('Announcement', 'non-existent id') + ); + }); +}); From 05e984c7bae905da3fd2f7c9aeaab534b6b32921 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sat, 21 Dec 2024 14:55:48 -0500 Subject: [PATCH 10/17] #3044 refactoring and minor fixes --- .../src/controllers/slack.controllers.ts | 16 ++ src/backend/src/integrations/slack.ts | 8 +- src/backend/src/routes/slack.routes.ts | 11 +- src/backend/src/services/slack.services.ts | 159 +++--------------- src/backend/src/utils/slack.utils.ts | 93 +++++++++- .../slackMessages.test.ts | 71 -------- .../{unmocked => unit}/announcements.test.ts | 0 .../{unmocked => unit}/design-review.test.ts | 0 .../{unmocked => unit}/notifications.test.ts | 0 .../{mocked => unit}/organization.test.ts | 0 .../{unmocked => unit}/recruitment.test.ts | 0 .../reimbursement-requests.test.ts | 0 .../{unmocked => unit}/team-type.test.ts | 0 .../tests/{unmocked => unit}/users.test.ts | 0 .../work-package-template.test.ts | 0 15 files changed, 135 insertions(+), 223 deletions(-) create mode 100644 src/backend/src/controllers/slack.controllers.ts rename src/backend/tests/{mocked => integration}/slackMessages.test.ts (84%) rename src/backend/tests/{unmocked => unit}/announcements.test.ts (100%) rename src/backend/tests/{unmocked => unit}/design-review.test.ts (100%) rename src/backend/tests/{unmocked => unit}/notifications.test.ts (100%) rename src/backend/tests/{mocked => unit}/organization.test.ts (100%) rename src/backend/tests/{unmocked => unit}/recruitment.test.ts (100%) rename src/backend/tests/{unmocked => unit}/reimbursement-requests.test.ts (100%) rename src/backend/tests/{unmocked => unit}/team-type.test.ts (100%) rename src/backend/tests/{unmocked => unit}/users.test.ts (100%) rename src/backend/tests/{unmocked => unit}/work-package-template.test.ts (100%) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts new file mode 100644 index 0000000000..def03d6870 --- /dev/null +++ b/src/backend/src/controllers/slack.controllers.ts @@ -0,0 +1,16 @@ +import { getWorkspaceId } from '../integrations/slack'; +import OrganizationsService from '../services/organizations.services'; +import slackServices from '../services/slack.services'; + +export default class SlackController { + static async processMessageEvent(event: any) { + try { + const organizations = await OrganizationsService.getAllOrganizations(); + const nerSlackWorkspaceId = await getWorkspaceId(); + const orgId = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId)?.organizationId; + if (orgId) { + slackServices.processMessageSent(event, orgId); + } + } catch (error: unknown) {} + } +} diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index 7c80fa819a..b24615743e 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -182,12 +182,12 @@ export const getUsersInChannel = async (channelId: string) => { return members; } catch (error) { - throw new HttpException(500, 'Error getting members from a slack channel: ' + (error as any).data.error); + return []; } }; /** - * Given a slack channel id, prood.uces the name of the channel + * Given a slack channel id, produces the name of the channel * @param channelId the id of the slack channel * @returns the name of the channel */ @@ -199,7 +199,7 @@ export const getChannelName = async (channelId: string) => { const channelRes = await slack.conversations.info({ channel: channelId }); return channelRes.channel?.name || 'Unknown Channel'; } catch (error) { - throw new HttpException(500, 'Error getting slack channel name: ' + (error as any).data.error); + return; } }; @@ -216,7 +216,7 @@ export const getUserName = async (userId: string) => { const userRes = await slack.users.info({ user: userId }); return userRes.user?.profile?.display_name || userRes.user?.real_name || 'Unkown User'; } catch (error) { - throw new HttpException(500, 'Error getting slack user name: ' + (error as any).data.error); + return; } }; diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index 82b98b0a0b..a085ae4244 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -1,17 +1,10 @@ import { createEventAdapter } from '@slack/events-api'; -import slackServices from '../services/slack.services'; -import OrganizationsService from '../services/organizations.services'; -import { getWorkspaceId } from '../integrations/slack'; +import SlackController from '../controllers/slack.controllers'; export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); slackEvents.on('message', async (event) => { - const organizations = await OrganizationsService.getAllOrganizations(); - const nerSlackWorkspaceId = await getWorkspaceId(); - const orgId = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId)?.organizationId; - if (orgId) { - slackServices.processMessageSent(event, orgId); - } + SlackController.processMessageEvent(event); }); slackEvents.on('error', (error) => { diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 991af8fc18..90c50995a9 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,8 +1,9 @@ import UsersService from './users.services'; -import { getChannelName, getUserName, getUsersInChannel } from '../integrations/slack'; -import { User_Settings } from '@prisma/client'; +import { getChannelName, getUserName } from '../integrations/slack'; import AnnouncementService from './announcement.service'; import { Announcement } from 'shared'; +import prisma from '../prisma/prisma'; +import { blockToMentionedUsers, blockToString } from '../utils/slack.utils'; /** * Represents a slack event for a message in a channel. @@ -66,98 +67,6 @@ export interface SlackRichTextBlock { } export default class slackServices { - /** - * Converts a SlackRichTextBlock into a string representation for an announcement. - * @param block the block of information from slack - * @returns the string that will be combined with other block's strings to create the announcement - */ - private static async blockToString(block: SlackRichTextBlock): Promise { - switch (block.type) { - case 'broadcast': - return '@' + block.range; - case 'color': - return block.value ?? ''; - case 'channel': - //channels are represented as an id, get the name from the slack api - let channelName = block.channel_id; - try { - channelName = await getChannelName(block.channel_id ?? ''); - } catch (error) { - channelName = `ISSUE PARSING CHANNEL:${block.channel_id}`; - } - return '#' + channelName; - case 'date': - return new Date(block.timestamp ?? 0).toISOString(); - case 'emoji': - //if the emoji is a unicode emoji, convert the unicode to a string, - //if it is a slack emoji just use the name of the emoji - if (block.unicode) { - return String.fromCodePoint(parseInt(block.unicode, 16)); - } - return 'emoji:' + block.name; - case 'link': - if (block.text) { - return `${block.text}:(${block.url})`; - } - return block.url ?? ''; - case 'text': - return block.text ?? ''; - case 'user': - //users are represented as an id, get the name of the user from the slack api - let userName: string = block.user_id ?? ''; - try { - userName = (await getUserName(block.user_id ?? '')) ?? `Unknown User:${block.user_id}`; - } catch (error) { - userName = `Unknown_User:${block.user_id}`; - } - return '@' + userName; - case 'usergroup': - return `usergroup:${block.usergroup_id}`; - } - } - - /** - * Gets the users notified in a specific SlackRichTextBlock. - * @param block the block that may contain mentioned user/users - * @param usersSettings the settings of all the users in prisma - * @param channelId the id of the channel that the block is being sent in - * @returns an array of prisma user ids of users to be notified - */ - private static async blockToMentionedUsers( - block: SlackRichTextBlock, - usersSettings: User_Settings[], - channelId: string - ): Promise { - switch (block.type) { - case 'broadcast': - switch (block.range) { - case 'everyone': - return usersSettings.map((usersSettings) => usersSettings.userId); - case 'channel': - case 'here': - //@here behaves the same as @channel; notifies all the users in that channel - let slackIds: string[] = []; - try { - slackIds = await getUsersInChannel(channelId); - } catch (ignored) {} - return usersSettings - .filter((userSettings) => { - return slackIds.some((slackId) => slackId === userSettings.slackId); - }) - .map((user) => user.userId); - default: - return []; - } - case 'user': - return usersSettings - .filter((userSettings) => userSettings.slackId === block.user_id) - .map((userSettings) => userSettings.userId); - default: - //only broadcasts and specific user mentions add recievers to announcements - return []; - } - } - /** * Given a slack event representing a message in a channel, * make the appropriate announcement change in prisma. @@ -166,13 +75,8 @@ export default class slackServices { * @returns an annoucement if an announcement was processed and created/modified/deleted */ static async processMessageSent(event: SlackMessageEvent, organizationId: string): Promise { - let slackChannelName: string; //get the name of the channel from the slack api - try { - slackChannelName = await getChannelName(event.channel); - } catch (error) { - slackChannelName = `Unknown_Channel:${event.channel}`; - } + const slackChannelName: string = (await getChannelName(event.channel)) ?? `Unknown_Channel:${event.channel}`; const dateCreated = new Date(Number(event.event_ts)); //get the message that will be processed either as the event or within a subtype @@ -183,11 +87,7 @@ export default class slackServices { case 'message_deleted': //delete the message using the client_msg_id eventMessage = (event as SlackDeletedMessage).previous_message; - try { - return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); - } catch (ignored) { - return; - } + return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); case 'message_changed': eventMessage = (event as SlackUpdatedMessage).message; break; @@ -213,22 +113,14 @@ export default class slackServices { ); //get the name of the user that sent the message from slack - let userName: string = ''; - try { - userName = (await getUserName(eventMessage.user)) ?? ''; - } catch (ignored) {} + let userName = (await getUserName(eventMessage.user)) ?? ''; //if slack could not produce the name of the user, look for their name in prisma if (!userName) { - const userIdList = userSettings - .filter((userSetting) => userSetting.slackId === eventMessage.user) - .map((userSettings) => userSettings.userId); - if (userIdList.length !== 0) { - const prismaUserName = users.find((user) => user.userId === userIdList[0]); - userName = prismaUserName - ? `${prismaUserName?.firstName} ${prismaUserName?.lastName}` - : 'Unknown User:' + eventMessage.user; - } else { + try { + const userWithThatSlackId = await prisma.user.findFirst({ where: { userSettings: { slackId: eventMessage.user } } }); + userName = `${userWithThatSlackId?.firstName} ${userWithThatSlackId?.lastName}`; + } catch { userName = 'Unknown_User:' + eventMessage.user; } } @@ -236,12 +128,10 @@ export default class slackServices { //pull out the blocks of data from the metadata within the message event const richTextBlocks = eventMessage.blocks?.filter((eventBlock: any) => eventBlock.type === 'rich_text'); - if (richTextBlocks && richTextBlocks.length === 1) { + if (richTextBlocks && richTextBlocks.length > 0 && richTextBlocks[0].elements.length > 0) { for (const element of richTextBlocks[0].elements[0].elements) { - messageText += await slackServices.blockToString(element); - userIdsToNotify = userIdsToNotify.concat( - await slackServices.blockToMentionedUsers(element, userSettings, event.channel) - ); + messageText += await blockToString(element); + userIdsToNotify = userIdsToNotify.concat(await blockToMentionedUsers(element, userSettings, event.channel)); } } else { return; @@ -271,19 +161,14 @@ export default class slackServices { ); } catch (ignored) {} } - try { - return await AnnouncementService.createAnnouncement( - messageText, - userIdsToNotify, - dateCreated, - userName, - eventMessage.client_msg_id, - slackChannelName, - organizationId - ); - } catch (error) { - //if announcement does not have unique cient_msg_id disregard it - return; - } + return await AnnouncementService.createAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); } } diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index ecc264610b..ea98a1a698 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -1,6 +1,14 @@ import { ChangeRequest, daysBetween, Task, UserPreview, wbsPipe, calculateEndDate } from 'shared'; -import { User } from '@prisma/client'; -import { editMessage, reactToMessage, replyToMessageInThread, sendMessage } from '../integrations/slack'; +import { User, User_Settings } from '@prisma/client'; +import { + editMessage, + getChannelName, + getUserName, + getUsersInChannel, + reactToMessage, + replyToMessageInThread, + sendMessage +} from '../integrations/slack'; import { getUserFullName, getUserSlackId } from './users.utils'; import prisma from '../prisma/prisma'; import { HttpException } from './errors.utils'; @@ -11,6 +19,7 @@ import { addHours, meetingStartTimePipe } from './design-reviews.utils'; import { WorkPackageQueryArgs } from '../prisma-query-args/work-packages.query-args'; import { Prisma } from '@prisma/client'; import { userTransformer } from '../transformers/user.transformer'; +import { SlackRichTextBlock } from '../services/slack.services'; interface SlackMessageThread { messageInfoId: string; @@ -470,3 +479,83 @@ export const addSlackThreadsToChangeRequest = async (crId: string, threads: { ch ); await Promise.all(promises); }; + +/** + * Converts a SlackRichTextBlock into a string representation for an announcement. + * @param block the block of information from slack + * @returns the string that will be combined with other block's strings to create the announcement + */ +export const blockToString = async (block: SlackRichTextBlock) => { + switch (block.type) { + case 'broadcast': + return '@' + block.range; + case 'color': + return block.value ?? ''; + case 'channel': + //channels are represented as an id, get the name from the slack api + const channelName: string = + (await getChannelName(block.channel_id ?? '')) ?? `ISSUE PARSING CHANNEL:${block.channel_id}`; + return '#' + channelName; + case 'date': + return new Date(block.timestamp ?? 0).toISOString(); + case 'emoji': + //if the emoji is a unicode emoji, convert the unicode to a string, + //if it is a slack emoji just use the name of the emoji + if (block.unicode) { + return String.fromCodePoint(parseInt(block.unicode, 16)); + } + return 'emoji:' + block.name; + case 'link': + if (block.text) { + return `${block.text}:(${block.url})`; + } + return block.url ?? ''; + case 'text': + return block.text ?? ''; + case 'user': + //users are represented as an id, get the name of the user from the slack api + const userName: string = (await getUserName(block.user_id ?? '')) ?? `Unknown User:${block.user_id}`; + return '@' + userName; + case 'usergroup': + return `usergroup:${block.usergroup_id}`; + } +}; + +/** + * Gets the users notified in a specific SlackRichTextBlock. + * @param block the block that may contain mentioned user/users + * @param usersSettings the settings of all the users in prisma + * @param channelId the id of the channel that the block is being sent in + * @returns an array of prisma user ids of users to be notified + */ +export const blockToMentionedUsers = async ( + block: SlackRichTextBlock, + usersSettings: User_Settings[], + channelId: string +) => { + switch (block.type) { + case 'broadcast': + switch (block.range) { + case 'everyone': + return usersSettings.map((usersSettings) => usersSettings.userId); + case 'channel': + case 'here': + //@here behaves the same as @channel; notifies all the users in that channel + const slackIds: string[] = await getUsersInChannel(channelId); + return usersSettings + .filter((userSettings) => { + return slackIds.some((slackId) => slackId === userSettings.slackId); + }) + .map((user) => user.userId); + default: + return []; + } + case 'user': + return usersSettings + .filter((userSettings) => userSettings.slackId === block.user_id) + .map((userSettings) => userSettings.userId); + default: + //only broadcasts and specific user mentions add recievers to announcements + return []; + } +}; diff --git a/src/backend/tests/mocked/slackMessages.test.ts b/src/backend/tests/integration/slackMessages.test.ts similarity index 84% rename from src/backend/tests/mocked/slackMessages.test.ts rename to src/backend/tests/integration/slackMessages.test.ts index f8fc76f4ab..99319d0bb0 100644 --- a/src/backend/tests/mocked/slackMessages.test.ts +++ b/src/backend/tests/integration/slackMessages.test.ts @@ -12,7 +12,6 @@ import * as apiFunctions from '../../src/integrations/slack'; import AnnouncementService from '../../src/services/announcement.service'; import slackServices from '../../src/services/slack.services'; import { vi } from 'vitest'; -import { HttpException } from '../../src/utils/errors.utils'; vi.mock('../../src/integrations/slack', async (importOriginal) => { return { @@ -198,46 +197,6 @@ describe('Slack message tests', () => { expect(announcement?.usersReceived).toHaveLength(1); }); - it('Deals with errors from slack API', async () => { - vi.mocked(apiFunctions.getUserName).mockImplementation(() => { - throw new HttpException(500, 'sample error'); - }); - vi.mocked(apiFunctions.getChannelName).mockImplementation(() => { - throw new HttpException(500, 'sample error'); - }); - - const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); - - const announcement = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'slackWW', 'id_1', [ - { type: 'user', user_id: 'slackWW' }, - { type: 'text', text: ' prisma user and non-prisma user ' }, - { type: 'user', user_id: 'non-prisma-slack-id' } - ]), - orgId - ); - - expect(spy).toBeCalledTimes(1); - expect(spy).toBeCalledWith( - '@Unknown_User:slackWW prisma user and non-prisma user @Unknown_User:non-prisma-slack-id', - [wonderwoman.userId], - new Date(1000000000000), - 'Wonder Woman', - 'id_1', - 'Unknown_Channel:channel id', - orgId - ); - - expect(announcement?.text).toBe( - '@Unknown_User:slackWW prisma user and non-prisma user @Unknown_User:non-prisma-slack-id' - ); - expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); - expect(announcement?.senderName).toBe('Wonder Woman'); - expect(announcement?.slackChannelName).toBe('Unknown_Channel:channel id'); - expect(announcement?.slackEventId).toBe('id_1'); - expect(announcement?.usersReceived).toHaveLength(1); - }); - it("Doesn't create an announcement if no one is mentioned", async () => { vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); @@ -256,36 +215,6 @@ describe('Slack message tests', () => { expect(announcement).toBeUndefined(); }); - it('Does nothing if an announcement with the same slack id has already been created', async () => { - vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); - vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); - - const createSpy = vi.spyOn(AnnouncementService, 'createAnnouncement'); - - await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), - orgId - ); - expect(createSpy).toBeCalledWith( - '@Slack User Name', - [wonderwoman.userId], - new Date(1000000000000), - 'Slack User Name', - 'id_1', - 'Slack Channel Name', - orgId - ); - - const announcement2 = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ - { type: 'user', user_id: 'slackWW' }, - { type: 'text', text: ' added text' } - ]), - orgId - ); - expect(announcement2).toBeUndefined(); - }); - it('Updates an edit made to a message', async () => { vi.mocked(apiFunctions.getUserName).mockReturnValue(Promise.resolve('Slack User Name')); vi.mocked(apiFunctions.getChannelName).mockReturnValue(Promise.resolve('Slack Channel Name')); diff --git a/src/backend/tests/unmocked/announcements.test.ts b/src/backend/tests/unit/announcements.test.ts similarity index 100% rename from src/backend/tests/unmocked/announcements.test.ts rename to src/backend/tests/unit/announcements.test.ts diff --git a/src/backend/tests/unmocked/design-review.test.ts b/src/backend/tests/unit/design-review.test.ts similarity index 100% rename from src/backend/tests/unmocked/design-review.test.ts rename to src/backend/tests/unit/design-review.test.ts diff --git a/src/backend/tests/unmocked/notifications.test.ts b/src/backend/tests/unit/notifications.test.ts similarity index 100% rename from src/backend/tests/unmocked/notifications.test.ts rename to src/backend/tests/unit/notifications.test.ts diff --git a/src/backend/tests/mocked/organization.test.ts b/src/backend/tests/unit/organization.test.ts similarity index 100% rename from src/backend/tests/mocked/organization.test.ts rename to src/backend/tests/unit/organization.test.ts diff --git a/src/backend/tests/unmocked/recruitment.test.ts b/src/backend/tests/unit/recruitment.test.ts similarity index 100% rename from src/backend/tests/unmocked/recruitment.test.ts rename to src/backend/tests/unit/recruitment.test.ts diff --git a/src/backend/tests/unmocked/reimbursement-requests.test.ts b/src/backend/tests/unit/reimbursement-requests.test.ts similarity index 100% rename from src/backend/tests/unmocked/reimbursement-requests.test.ts rename to src/backend/tests/unit/reimbursement-requests.test.ts diff --git a/src/backend/tests/unmocked/team-type.test.ts b/src/backend/tests/unit/team-type.test.ts similarity index 100% rename from src/backend/tests/unmocked/team-type.test.ts rename to src/backend/tests/unit/team-type.test.ts diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unit/users.test.ts similarity index 100% rename from src/backend/tests/unmocked/users.test.ts rename to src/backend/tests/unit/users.test.ts diff --git a/src/backend/tests/unmocked/work-package-template.test.ts b/src/backend/tests/unit/work-package-template.test.ts similarity index 100% rename from src/backend/tests/unmocked/work-package-template.test.ts rename to src/backend/tests/unit/work-package-template.test.ts From 74b287f47ce7c3b03e4d46f4cc04fdb2790216cc Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Sun, 22 Dec 2024 11:02:44 -0500 Subject: [PATCH 11/17] #3044 checking for notFound error and changed how we get org id --- src/backend/src/controllers/slack.controllers.ts | 6 +++--- src/backend/src/services/slack.services.ts | 8 +++++++- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index def03d6870..d90c60ac35 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -7,9 +7,9 @@ export default class SlackController { try { const organizations = await OrganizationsService.getAllOrganizations(); const nerSlackWorkspaceId = await getWorkspaceId(); - const orgId = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId)?.organizationId; - if (orgId) { - slackServices.processMessageSent(event, orgId); + const relatedOrganization = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId); + if (relatedOrganization) { + slackServices.processMessageSent(event, relatedOrganization.organizationId); } } catch (error: unknown) {} } diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 90c50995a9..c94835d49d 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -4,6 +4,7 @@ import AnnouncementService from './announcement.service'; import { Announcement } from 'shared'; import prisma from '../prisma/prisma'; import { blockToMentionedUsers, blockToString } from '../utils/slack.utils'; +import { NotFoundException } from '../utils/errors.utils'; /** * Represents a slack event for a message in a channel. @@ -159,7 +160,12 @@ export default class slackServices { slackChannelName, organizationId ); - } catch (ignored) {} + } catch (error) { + //if couldn't find the announcement to edit, create a new one below + if (!(error instanceof NotFoundException)) { + throw error; + } + } } return await AnnouncementService.createAnnouncement( messageText, From 1bd863a361a9b9a9d1307deb4f90fe89704fe434 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 24 Dec 2024 09:58:45 -0500 Subject: [PATCH 12/17] #3044-fixed date --- src/backend/src/services/slack.services.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 5496922d23..57a7b70023 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -78,7 +78,7 @@ export default class slackServices { static async processMessageSent(event: SlackMessageEvent, organizationId: string): Promise { //get the name of the channel from the slack api const slackChannelName: string = (await getChannelName(event.channel)) ?? `Unknown_Channel:${event.channel}`; - const dateCreated = new Date(Number(event.event_ts)); + const dateCreated = new Date(1000 * Number(event.event_ts)); //get the message that will be processed either as the event or within a subtype let eventMessage: SlackMessage; From c94172f84e49905223452a64e3053cb9703c445f Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 24 Dec 2024 15:24:59 -0500 Subject: [PATCH 13/17] #3044-fixed tests --- src/backend/src/services/slack.services.ts | 2 - .../tests/integration/slackMessages.test.ts | 65 +++++++++---------- src/backend/tests/unit/announcements.test.ts | 45 +++++++++++-- 3 files changed, 72 insertions(+), 40 deletions(-) diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 57a7b70023..8bda71edcd 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -146,8 +146,6 @@ export default class slackServices { return; } - console.log('processed event'); - if (event.subtype === 'message_changed') { //try to edit the announcement, if no announcement with that id exists create a new announcement try { diff --git a/src/backend/tests/integration/slackMessages.test.ts b/src/backend/tests/integration/slackMessages.test.ts index 99319d0bb0..f870629260 100644 --- a/src/backend/tests/integration/slackMessages.test.ts +++ b/src/backend/tests/integration/slackMessages.test.ts @@ -30,6 +30,7 @@ describe('Slack message tests', () => { let wonderwoman: User; beforeEach(async () => { + await resetUsers(); organization = await createTestOrganization(); orgId = organization.organizationId; batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); @@ -49,7 +50,7 @@ describe('Slack message tests', () => { const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); const announcement = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'text', text: 'test with ' }, { type: 'broadcast', range: 'everyone' }, { type: 'text', text: ' broadcast (@everyone)' } @@ -63,7 +64,7 @@ describe('Slack message tests', () => { expect(spy).toBeCalledWith( 'test with @everyone broadcast (@everyone)', [organization.userCreatedId, batman.userId, superman.userId, wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -71,7 +72,7 @@ describe('Slack message tests', () => { ); expect(announcement?.text).toBe('test with @everyone broadcast (@everyone)'); - expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); expect(announcement?.senderName).toBe('Slack User Name'); expect(announcement?.slackChannelName).toBe('Slack Channel Name'); expect(announcement?.slackEventId).toBe('id_1'); @@ -86,7 +87,7 @@ describe('Slack message tests', () => { const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); const announcement = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'text', text: 'test with ' }, { type: 'broadcast', range: 'channel' }, { type: 'text', text: ' broadcast (@channel)' }, @@ -100,7 +101,7 @@ describe('Slack message tests', () => { expect(spy).toBeCalledWith( 'test with @channel broadcast (@channel)@Slack User Name@Slack User Name', [batman.userId, wonderwoman.userId, superman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -108,7 +109,7 @@ describe('Slack message tests', () => { ); expect(announcement?.text).toBe('test with @channel broadcast (@channel)@Slack User Name@Slack User Name'); - expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); expect(announcement?.senderName).toBe('Slack User Name'); expect(announcement?.slackChannelName).toBe('Slack Channel Name'); expect(announcement?.slackEventId).toBe('id_1'); @@ -122,7 +123,7 @@ describe('Slack message tests', () => { const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); const announcement = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'text', text: 'test with ' }, { type: 'user', user_id: 'slackWW' }, { type: 'text', text: ' broadcast (@wonderwoman)' } @@ -134,7 +135,7 @@ describe('Slack message tests', () => { expect(spy).toBeCalledWith( 'test with @Slack User Name broadcast (@wonderwoman)', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -142,7 +143,7 @@ describe('Slack message tests', () => { ); expect(announcement?.text).toBe('test with @Slack User Name broadcast (@wonderwoman)'); - expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); expect(announcement?.senderName).toBe('Slack User Name'); expect(announcement?.slackChannelName).toBe('Slack Channel Name'); expect(announcement?.slackEventId).toBe('id_1'); @@ -156,7 +157,7 @@ describe('Slack message tests', () => { const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); const announcement = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'text', text: 'test with: ' }, { type: 'link', url: 'http://www.example.com', text: 'link' }, { type: 'text', text: 'Italics', style: { italic: true } }, @@ -180,7 +181,7 @@ describe('Slack message tests', () => { expect(spy).toBeCalledWith( 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -190,7 +191,7 @@ describe('Slack message tests', () => { expect(announcement?.text).toBe( 'test with: link:(http://www.example.com)Italics and a unicode emoji: 😝 and a slack emoji: emoji:birthday-parrot@Slack User Name' ); - expect(announcement?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); expect(announcement?.senderName).toBe('Slack User Name'); expect(announcement?.slackChannelName).toBe('Slack Channel Name'); expect(announcement?.slackEventId).toBe('id_1'); @@ -204,9 +205,7 @@ describe('Slack message tests', () => { const spy = vi.spyOn(AnnouncementService, 'createAnnouncement'); const announcement = await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ - { type: 'text', text: 'just a text message' } - ]), + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [{ type: 'text', text: 'just a text message' }]), orgId ); @@ -223,13 +222,13 @@ describe('Slack message tests', () => { const updateSpy = vi.spyOn(AnnouncementService, 'updateAnnouncement'); await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), orgId ); expect(createSpy).toBeCalledWith( '@Slack User Name', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -241,13 +240,13 @@ describe('Slack message tests', () => { type: 'message', subtype: 'message_changed', channel: 'channel id', - event_ts: '1000000000000', + event_ts: '1', channel_type: 'channel', - message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'user', user_id: 'slackWW' }, { type: 'text', text: ' added text' } ]), - previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'user', user_id: 'slackWW' } ]) }, @@ -257,7 +256,7 @@ describe('Slack message tests', () => { expect(updateSpy).toBeCalledWith( '@Slack User Name added text', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -265,7 +264,7 @@ describe('Slack message tests', () => { ); expect(announcement2?.text).toBe('@Slack User Name added text'); - expect(announcement2?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement2?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); expect(announcement2?.senderName).toBe('Slack User Name'); expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); expect(announcement2?.slackEventId).toBe('id_1'); @@ -287,13 +286,13 @@ describe('Slack message tests', () => { type: 'message', subtype: 'message_changed', channel: 'channel id', - event_ts: '1000000000000', + event_ts: '1', channel_type: 'channel', - message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'user', user_id: 'slackWW' }, { type: 'text', text: ' added text' } ]), - previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'user', user_id: 'slackWW' } ]) }, @@ -303,7 +302,7 @@ describe('Slack message tests', () => { expect(updateSpy).toBeCalledWith( '@Slack User Name added text', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -313,7 +312,7 @@ describe('Slack message tests', () => { expect(createSpy).toBeCalledWith( '@Slack User Name added text', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -321,7 +320,7 @@ describe('Slack message tests', () => { ); expect(announcement2?.text).toBe('@Slack User Name added text'); - expect(announcement2?.dateCreated.toDateString()).toBe(new Date(1000000000000).toDateString()); + expect(announcement2?.dateMessageSent.toDateString()).toBe(new Date(1000).toDateString()); expect(announcement2?.senderName).toBe('Slack User Name'); expect(announcement2?.slackChannelName).toBe('Slack Channel Name'); expect(announcement2?.slackEventId).toBe('id_1'); @@ -339,13 +338,13 @@ describe('Slack message tests', () => { const deleteSpy = vi.spyOn(AnnouncementService, 'deleteAnnouncement'); await slackServices.processMessageSent( - createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), orgId ); expect(createSpy).toBeCalledWith( '@Slack User Name', [wonderwoman.userId], - new Date(1000000000000), + new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -357,9 +356,9 @@ describe('Slack message tests', () => { type: 'message', subtype: 'message_deleted', channel: 'channel id', - event_ts: '1000000000000', + event_ts: '1', channel_type: 'channel', - previous_message: createSlackMessageEvent('channel id', '1000000000000', 'user name', 'id_1', [ + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ { type: 'user', user_id: 'slackWW' } ]) }, @@ -381,7 +380,7 @@ describe('Slack message tests', () => { type: 'message', subtype: 'other-nonprocessed-subtype', channel: 'channel id', - event_ts: '1000000000000', + event_ts: '1', channel_type: 'channel', bogus_data: 'other data' }, diff --git a/src/backend/tests/unit/announcements.test.ts b/src/backend/tests/unit/announcements.test.ts index 162bd9965e..6eb0ec5ab6 100644 --- a/src/backend/tests/unit/announcements.test.ts +++ b/src/backend/tests/unit/announcements.test.ts @@ -19,6 +19,7 @@ describe('announcement tests', () => { let wonderwoman: User; beforeEach(async () => { + await resetUsers(); organization = await createTestOrganization(); orgId = organization.organizationId; batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); @@ -157,10 +158,9 @@ describe('announcement tests', () => { describe('Get Announcements', () => { it('Succeeds and gets user announcements', async () => { - const testBatman = await createTestUser(batmanAppAdmin, orgId); await AnnouncementService.createAnnouncement( 'test1', - [testBatman.userId], + [batman.userId], new Date(), 'Thomas Emrax', '1', @@ -169,7 +169,7 @@ describe('announcement tests', () => { ); await AnnouncementService.createAnnouncement( 'test2', - [testBatman.userId], + [batman.userId], new Date(), 'Superman', '50', @@ -177,14 +177,49 @@ describe('announcement tests', () => { organization.organizationId ); - const announcements = await AnnouncementService.getUserUnreadAnnouncements( - testBatman.userId, + const announcements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, organization.organizationId); + + expect(announcements).toHaveLength(2); + expect(announcements[0].text).toBe('test1'); + expect(announcements[1].text).toBe('test2'); + }); + }); + + describe('Remove Announcement', () => { + it('Succeeds and removes user announcement', async () => { + await AnnouncementService.createAnnouncement( + 'test1', + [batman.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', organization.organizationId ); + await AnnouncementService.createAnnouncement( + 'test2', + [batman.userId], + new Date(), + 'Superman', + '50', + 'mechanical', + organization.organizationId + ); + + const announcements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, organization.organizationId); expect(announcements).toHaveLength(2); expect(announcements[0].text).toBe('test1'); expect(announcements[1].text).toBe('test2'); + + const updatedAnnouncements = await AnnouncementService.removeUserAnnouncement( + batman.userId, + announcements[0].announcementId, + organization.organizationId + ); + + expect(updatedAnnouncements).toHaveLength(1); + expect(updatedAnnouncements[0].text).toBe('test2'); }); }); }); From 4d181292877f786ceb4ba65a731796349cb69ac5 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Tue, 24 Dec 2024 15:30:46 -0500 Subject: [PATCH 14/17] #3044-small mistake --- src/backend/tests/test-utils.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index 5d3f3eea76..fc6698ff06 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -122,7 +122,6 @@ export const resetUsers = async () => { await prisma.organization.deleteMany(); await prisma.announcement.deleteMany(); await prisma.user.deleteMany(); - await prisma.announcement.deleteMany(); }; export const createFinanceTeamAndLead = async (organization?: Organization) => { From 1dbb5502c7cb4a040f8717a6b5e1b58de62e7408 Mon Sep 17 00:00:00 2001 From: Chris Pyle Date: Fri, 27 Dec 2024 18:53:10 -0500 Subject: [PATCH 15/17] #3044 refactoring prisma queries and misc changes --- .../src/controllers/slack.controllers.ts | 4 +- src/backend/src/integrations/slack.ts | 23 +++------- src/backend/src/routes/slack.routes.ts | 8 +--- .../src/services/announcement.service.ts | 8 ++-- src/backend/src/services/slack.services.ts | 12 +----- src/backend/src/utils/slack.utils.ts | 43 +++++++++++++------ .../tests/integration/slackMessages.test.ts | 19 +++++--- src/backend/tests/unit/announcements.test.ts | 2 - 8 files changed, 61 insertions(+), 58 deletions(-) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index d90c60ac35..c16dcfeaba 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -11,6 +11,8 @@ export default class SlackController { if (relatedOrganization) { slackServices.processMessageSent(event, relatedOrganization.organizationId); } - } catch (error: unknown) {} + } catch (error: unknown) { + console.log(error); + } } } diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index b24615743e..6e855acae7 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -182,41 +182,35 @@ export const getUsersInChannel = async (channelId: string) => { return members; } catch (error) { - return []; + return members; } }; /** * Given a slack channel id, produces the name of the channel * @param channelId the id of the slack channel - * @returns the name of the channel + * @returns the name of the channel or undefined if it cannot be found */ export const getChannelName = async (channelId: string) => { - const { SLACK_BOT_TOKEN } = process.env; - if (!SLACK_BOT_TOKEN) return channelId; - try { const channelRes = await slack.conversations.info({ channel: channelId }); - return channelRes.channel?.name || 'Unknown Channel'; + return channelRes.channel?.name; } catch (error) { - return; + return undefined; } }; /** * Given a slack user id, prood.uces the name of the channel * @param userId the id of the slack user - * @returns the name of the user (real name if no display name) + * @returns the name of the user (real name if no display name), undefined if cannot be found */ export const getUserName = async (userId: string) => { - const { SLACK_BOT_TOKEN } = process.env; - if (!SLACK_BOT_TOKEN) return; - try { const userRes = await slack.users.info({ user: userId }); - return userRes.user?.profile?.display_name || userRes.user?.real_name || 'Unkown User'; + return userRes.user?.profile?.display_name || userRes.user?.real_name; } catch (error) { - return; + return undefined; } }; @@ -225,9 +219,6 @@ export const getUserName = async (userId: string) => { * @returns the id of the workspace */ export const getWorkspaceId = async () => { - const { SLACK_BOT_TOKEN } = process.env; - if (!SLACK_BOT_TOKEN) return; - try { const response = await slack.auth.test(); if (response.ok) { diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index a085ae4244..6878b176b1 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -3,10 +3,6 @@ import SlackController from '../controllers/slack.controllers'; export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); -slackEvents.on('message', async (event) => { - SlackController.processMessageEvent(event); -}); +slackEvents.on('message', SlackController.processMessageEvent); -slackEvents.on('error', (error) => { - console.log(error.name); -}); +slackEvents.on('error', console.log); diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index c885543059..64bb6a80da 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -2,7 +2,7 @@ import { Announcement } from 'shared'; import prisma from '../prisma/prisma'; import { getAnnouncementQueryArgs } from '../prisma-query-args/announcements.query.args'; import announcementTransformer from '../transformers/announcements.transformer'; -import { HttpException, NotFoundException } from '../utils/errors.utils'; +import { DeletedException, HttpException, NotFoundException } from '../utils/errors.utils'; export default class AnnouncementService { /** @@ -48,7 +48,6 @@ export default class AnnouncementService { static async updateAnnouncement( text: string, usersReceivedIds: string[], - dateMessageSent: Date, senderName: string, slackEventId: string, slackChannelName: string, @@ -62,6 +61,8 @@ export default class AnnouncementService { if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + if (originalAnnouncement.dateDeleted) throw new DeletedException('Announcement', slackEventId); + const announcement = await prisma.announcement.update({ where: { announcementId: originalAnnouncement.announcementId }, data: { @@ -72,7 +73,6 @@ export default class AnnouncementService { })) }, slackEventId, - dateMessageSent, senderName, slackChannelName }, @@ -91,6 +91,8 @@ export default class AnnouncementService { if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + if (originalAnnouncement.dateDeleted) throw new DeletedException('Announcement', slackEventId); + const announcement = await prisma.announcement.update({ where: { slackEventId }, data: { diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 8bda71edcd..5bdafb7413 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,4 +1,3 @@ -import UsersService from './users.services'; import { getChannelName, getUserName } from '../integrations/slack'; import AnnouncementService from './announcement.service'; import { Announcement } from 'shared'; @@ -105,14 +104,6 @@ export default class slackServices { let messageText = ''; let userIdsToNotify: string[] = []; - //Get the settings of all users in this organization to compare slack ids - const users = await UsersService.getAllUsers(); - const userSettings = await Promise.all( - users.map((user) => { - return UsersService.getUserSettings(user.userId); - }) - ); - //get the name of the user that sent the message from slack let userName = (await getUserName(eventMessage.user)) ?? ''; @@ -132,7 +123,7 @@ export default class slackServices { if (richTextBlocks && richTextBlocks.length > 0 && richTextBlocks[0].elements.length > 0) { for (const element of richTextBlocks[0].elements[0].elements) { messageText += await blockToString(element); - userIdsToNotify = userIdsToNotify.concat(await blockToMentionedUsers(element, userSettings, event.channel)); + userIdsToNotify = userIdsToNotify.concat(await blockToMentionedUsers(element, organizationId, event.channel)); } } else { return; @@ -152,7 +143,6 @@ export default class slackServices { return await AnnouncementService.updateAnnouncement( messageText, userIdsToNotify, - dateCreated, userName, eventMessage.client_msg_id, slackChannelName, diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index ea98a1a698..4019c6d6db 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -1,5 +1,5 @@ import { ChangeRequest, daysBetween, Task, UserPreview, wbsPipe, calculateEndDate } from 'shared'; -import { User, User_Settings } from '@prisma/client'; +import { User } from '@prisma/client'; import { editMessage, getChannelName, @@ -20,6 +20,7 @@ import { WorkPackageQueryArgs } from '../prisma-query-args/work-packages.query-a import { Prisma } from '@prisma/client'; import { userTransformer } from '../transformers/user.transformer'; import { SlackRichTextBlock } from '../services/slack.services'; +import UsersService from '../services/users.services'; interface SlackMessageThread { messageInfoId: string; @@ -524,38 +525,54 @@ export const blockToString = async (block: SlackRichTextBlock) => { /** * Gets the users notified in a specific SlackRichTextBlock. * @param block the block that may contain mentioned user/users - * @param usersSettings the settings of all the users in prisma + * @param orgainzationId the id of the organization corresponding to this slack channel * @param channelId the id of the channel that the block is being sent in * @returns an array of prisma user ids of users to be notified */ export const blockToMentionedUsers = async ( block: SlackRichTextBlock, - usersSettings: User_Settings[], + organizationId: string, channelId: string -) => { +): Promise => { switch (block.type) { case 'broadcast': switch (block.range) { case 'everyone': - return usersSettings.map((usersSettings) => usersSettings.userId); + const usersInOrg = await UsersService.getAllUsers(organizationId); + return usersInOrg.map((user) => user.userId); case 'channel': case 'here': //@here behaves the same as @channel; notifies all the users in that channel const slackIds: string[] = await getUsersInChannel(channelId); - return usersSettings - .filter((userSettings) => { - return slackIds.some((slackId) => slackId === userSettings.slackId); - }) - .map((user) => user.userId); + const prismaIds: (string | undefined)[] = await Promise.all(slackIds.map(getUserIdFromSlackId)); + return prismaIds.filter((id): id is string => id !== undefined); default: return []; } case 'user': - return usersSettings - .filter((userSettings) => userSettings.slackId === block.user_id) - .map((userSettings) => userSettings.userId); + const prismaId = await getUserIdFromSlackId(block.user_id ?? ''); + return prismaId ? [prismaId] : []; default: //only broadcasts and specific user mentions add recievers to announcements return []; } }; + +/** + * given a slack id, produce the user id of the corresponding user + * @param slackId the slack id in the settings of the user + * @returns the user id, or undefined if no users were found + */ +export const getUserIdFromSlackId = async (slackId: string): Promise => { + const user = await prisma.user.findFirst({ + where: { + userSettings: { + slackId + } + } + }); + + if (!user) return undefined; + + return user.userId; +}; diff --git a/src/backend/tests/integration/slackMessages.test.ts b/src/backend/tests/integration/slackMessages.test.ts index f870629260..703db97c1a 100644 --- a/src/backend/tests/integration/slackMessages.test.ts +++ b/src/backend/tests/integration/slackMessages.test.ts @@ -12,6 +12,7 @@ import * as apiFunctions from '../../src/integrations/slack'; import AnnouncementService from '../../src/services/announcement.service'; import slackServices from '../../src/services/slack.services'; import { vi } from 'vitest'; +import prisma from '../../src/prisma/prisma'; vi.mock('../../src/integrations/slack', async (importOriginal) => { return { @@ -36,6 +37,16 @@ describe('Slack message tests', () => { batman = await createTestUser(batmanAppAdmin, orgId, batmanSettings); superman = await createTestUser(supermanAdmin, orgId, supermanSettings); wonderwoman = await createTestUser(wonderwomanGuest, orgId, wonderwomanSettings); + await prisma.organization.update({ + where: { + organizationId: orgId + }, + data: { + users: { + set: [{ userId: batman.userId }, { userId: superman.userId }, { userId: wonderwoman.userId }] + } + } + }); }); afterEach(async () => { @@ -58,12 +69,10 @@ describe('Slack message tests', () => { orgId ); - console.log(announcement); - expect(spy).toBeCalledTimes(1); expect(spy).toBeCalledWith( 'test with @everyone broadcast (@everyone)', - [organization.userCreatedId, batman.userId, superman.userId, wonderwoman.userId], + [batman.userId, superman.userId, wonderwoman.userId], new Date(1000), 'Slack User Name', 'id_1', @@ -76,7 +85,7 @@ describe('Slack message tests', () => { expect(announcement?.senderName).toBe('Slack User Name'); expect(announcement?.slackChannelName).toBe('Slack Channel Name'); expect(announcement?.slackEventId).toBe('id_1'); - expect(announcement?.usersReceived).toHaveLength(4); + expect(announcement?.usersReceived).toHaveLength(3); }); it('Adds message to people in channel with @channel and @mention (w/o duplicates)', async () => { @@ -256,7 +265,6 @@ describe('Slack message tests', () => { expect(updateSpy).toBeCalledWith( '@Slack User Name added text', [wonderwoman.userId], - new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', @@ -302,7 +310,6 @@ describe('Slack message tests', () => { expect(updateSpy).toBeCalledWith( '@Slack User Name added text', [wonderwoman.userId], - new Date(1000), 'Slack User Name', 'id_1', 'Slack Channel Name', diff --git a/src/backend/tests/unit/announcements.test.ts b/src/backend/tests/unit/announcements.test.ts index 6eb0ec5ab6..8f701ec41d 100644 --- a/src/backend/tests/unit/announcements.test.ts +++ b/src/backend/tests/unit/announcements.test.ts @@ -88,7 +88,6 @@ describe('announcement tests', () => { const updatedAnnouncement = await AnnouncementService.updateAnnouncement( 'new text', [batman.userId, wonderwoman.userId], - new Date(1000000000000), 'sender name', 'slack id', 'channel name', @@ -113,7 +112,6 @@ describe('announcement tests', () => { await AnnouncementService.updateAnnouncement( 'new text', [batman.userId, wonderwoman.userId], - new Date(1000000000000), 'sender name', 'slack id', 'channel name', From 659f88cdfd8d5d7eefa3a7030b14e84f6f067ab7 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Mon, 30 Dec 2024 17:32:26 -0500 Subject: [PATCH 16/17] #3044-added organization to announcements and pop-ups --- .../src/controllers/slack.controllers.ts | 4 ++-- .../migration.sql | 8 +++++++ src/backend/src/prisma/schema.prisma | 24 ++++++++++++------- .../src/services/announcement.service.ts | 13 ++++++++-- src/backend/src/services/pop-up.services.ts | 6 +++-- src/backend/src/services/slack.services.ts | 2 +- 6 files changed, 41 insertions(+), 16 deletions(-) rename src/backend/src/prisma/migrations/{20241222150147_homepage_redesign => 20241230221615_homepage_redesign}/migration.sql (83%) diff --git a/src/backend/src/controllers/slack.controllers.ts b/src/backend/src/controllers/slack.controllers.ts index c16dcfeaba..e7336711d2 100644 --- a/src/backend/src/controllers/slack.controllers.ts +++ b/src/backend/src/controllers/slack.controllers.ts @@ -1,6 +1,6 @@ import { getWorkspaceId } from '../integrations/slack'; import OrganizationsService from '../services/organizations.services'; -import slackServices from '../services/slack.services'; +import SlackServices from '../services/slack.services'; export default class SlackController { static async processMessageEvent(event: any) { @@ -9,7 +9,7 @@ export default class SlackController { const nerSlackWorkspaceId = await getWorkspaceId(); const relatedOrganization = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId); if (relatedOrganization) { - slackServices.processMessageSent(event, relatedOrganization.organizationId); + SlackServices.processMessageSent(event, relatedOrganization.organizationId); } } catch (error: unknown) { console.log(error); diff --git a/src/backend/src/prisma/migrations/20241222150147_homepage_redesign/migration.sql b/src/backend/src/prisma/migrations/20241230221615_homepage_redesign/migration.sql similarity index 83% rename from src/backend/src/prisma/migrations/20241222150147_homepage_redesign/migration.sql rename to src/backend/src/prisma/migrations/20241230221615_homepage_redesign/migration.sql index 17a36eb2d3..c62b11a4bb 100644 --- a/src/backend/src/prisma/migrations/20241222150147_homepage_redesign/migration.sql +++ b/src/backend/src/prisma/migrations/20241230221615_homepage_redesign/migration.sql @@ -14,6 +14,7 @@ CREATE TABLE "Announcement" ( "senderName" TEXT NOT NULL, "slackEventId" TEXT NOT NULL, "slackChannelName" TEXT NOT NULL, + "organizationId" TEXT NOT NULL, CONSTRAINT "Announcement_pkey" PRIMARY KEY ("announcementId") ); @@ -24,6 +25,7 @@ CREATE TABLE "PopUp" ( "text" TEXT NOT NULL, "iconName" TEXT NOT NULL, "eventLink" TEXT, + "organizationId" TEXT NOT NULL, CONSTRAINT "PopUp_pkey" PRIMARY KEY ("popUpId") ); @@ -58,6 +60,12 @@ CREATE INDEX "_userPopUps_B_index" ON "_userPopUps"("B"); -- AddForeignKey ALTER TABLE "Project" ADD CONSTRAINT "Project_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE SET NULL ON UPDATE CASCADE; +-- AddForeignKey +ALTER TABLE "Announcement" ADD CONSTRAINT "Announcement_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "PopUp" ADD CONSTRAINT "PopUp_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "Organization"("organizationId") ON DELETE RESTRICT ON UPDATE CASCADE; + -- AddForeignKey ALTER TABLE "_receivedAnnouncements" ADD CONSTRAINT "_receivedAnnouncements_A_fkey" FOREIGN KEY ("A") REFERENCES "Announcement"("announcementId") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index c62827da19..7bd7cb7fff 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -900,6 +900,8 @@ model Organization { FrequentlyAskedQuestions FrequentlyAskedQuestion[] Milestone Milestone[] featuredProjects Project[] + PopUps PopUp[] + Announcements Announcement[] } model FrequentlyAskedQuestion { @@ -932,20 +934,24 @@ model Milestone { } model Announcement { - announcementId String @id @default(uuid()) + announcementId String @id @default(uuid()) text String - usersReceived User[] @relation("receivedAnnouncements") - dateMessageSent DateTime @default(now()) + usersReceived User[] @relation("receivedAnnouncements") + dateMessageSent DateTime @default(now()) dateDeleted DateTime? senderName String - slackEventId String @unique + slackEventId String @unique slackChannelName String + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) } model PopUp { - popUpId String @id @default(uuid()) - text String - iconName String - users User[] @relation("userPopUps") - eventLink String? + popUpId String @id @default(uuid()) + text String + iconName String + users User[] @relation("userPopUps") + eventLink String? + organizationId String + organization Organization @relation(fields: [organizationId], references: [organizationId]) } diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index 64bb6a80da..6b5c758338 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -37,7 +37,8 @@ export default class AnnouncementService { dateMessageSent, senderName, slackEventId, - slackChannelName + slackChannelName, + organizationId }, ...getAnnouncementQueryArgs(organizationId) }); @@ -63,6 +64,9 @@ export default class AnnouncementService { if (originalAnnouncement.dateDeleted) throw new DeletedException('Announcement', slackEventId); + if (originalAnnouncement.organizationId !== organizationId) + throw new HttpException(400, `Announcement is not apart of the current organization`); + const announcement = await prisma.announcement.update({ where: { announcementId: originalAnnouncement.announcementId }, data: { @@ -93,6 +97,9 @@ export default class AnnouncementService { if (originalAnnouncement.dateDeleted) throw new DeletedException('Announcement', slackEventId); + if (originalAnnouncement.organizationId !== organizationId) + throw new HttpException(400, `Announcement is not apart of the current organization`); + const announcement = await prisma.announcement.update({ where: { slackEventId }, data: { @@ -116,9 +123,11 @@ export default class AnnouncementService { static async getUserUnreadAnnouncements(userId: string, organizationId: string) { const unreadAnnouncements = await prisma.announcement.findMany({ where: { + dateDeleted: null, usersReceived: { some: { userId } - } + }, + organizationId }, ...getAnnouncementQueryArgs(organizationId) }); diff --git a/src/backend/src/services/pop-up.services.ts b/src/backend/src/services/pop-up.services.ts index 267c72761f..61bba89bd0 100644 --- a/src/backend/src/services/pop-up.services.ts +++ b/src/backend/src/services/pop-up.services.ts @@ -15,7 +15,8 @@ export class PopUpService { where: { users: { some: { userId } - } + }, + organizationId }, ...getPopUpQueryArgs(organizationId) }); @@ -70,7 +71,8 @@ export class PopUpService { data: { text, iconName, - eventLink + eventLink, + organizationId }, ...getPopUpQueryArgs(organizationId) }); diff --git a/src/backend/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 5bdafb7413..f6ece7ba85 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -66,7 +66,7 @@ export interface SlackRichTextBlock { usergroup_id?: string; } -export default class slackServices { +export default class SlackServices { /** * Given a slack event representing a message in a channel, * make the appropriate announcement change in prisma. From 1d118e086a182b6058c5574190e354e1d412e254 Mon Sep 17 00:00:00 2001 From: caiodasilva2005 Date: Mon, 30 Dec 2024 17:38:19 -0500 Subject: [PATCH 17/17] #3044-fixed tests --- src/backend/tests/test-utils.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/src/backend/tests/test-utils.ts b/src/backend/tests/test-utils.ts index fc6698ff06..29070fee07 100644 --- a/src/backend/tests/test-utils.ts +++ b/src/backend/tests/test-utils.ts @@ -119,8 +119,9 @@ export const resetUsers = async () => { await prisma.wBS_Element.deleteMany(); await prisma.milestone.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); - await prisma.organization.deleteMany(); await prisma.announcement.deleteMany(); + await prisma.popUp.deleteMany(); + await prisma.organization.deleteMany(); await prisma.user.deleteMany(); };