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 4a1b41c0b1..abe9cc0a61 100644 --- a/src/backend/index.ts +++ b/src/backend/index.ts @@ -17,7 +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'; +import { slackEvents } from './src/routes/slack.routes'; const app = express(); @@ -41,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()); @@ -69,7 +73,6 @@ 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.status(200).json('Welcome to FinishLine'); }); diff --git a/src/backend/src/controllers/users.controllers.ts b/src/backend/src/controllers/users.controllers.ts index e491d3f4f4..b3fee931c2 100644 --- a/src/backend/src/controllers/users.controllers.ts +++ b/src/backend/src/controllers/users.controllers.ts @@ -194,13 +194,39 @@ export default class UsersController { static async getUserUnreadNotifications(req: Request, res: Response, next: NextFunction) { try { - const { userId } = req.params; - const { organization } = req; + const { organization, currentUser } = req; + + const unreadNotifications = await UsersService.getUserUnreadNotifications(currentUser.userId, organization); + res.status(200).json(unreadNotifications); + } catch (error: unknown) { + next(error); + } + } + + static async removeUserNotification(req: Request, res: Response, next: NextFunction) { + try { + const { notificationId } = req.params; + const { organization, currentUser } = req; - const unreadNotifications = await UsersService.getUserUnreadNotifications(userId, organization); + const unreadNotifications = await UsersService.removeUserNotification( + currentUser.userId, + notificationId, + organization + ); res.status(200).json(unreadNotifications); } catch (error: unknown) { next(error); } } + + static async getUserUnreadAnnouncements(req: Request, res: Response, next: NextFunction) { + try { + const { organization, currentUser } = req; + + const unreadAnnouncements = await UsersService.getUserUnreadAnnouncements(currentUser.userId, organization); + res.status(200).json(unreadAnnouncements); + } catch (error: unknown) { + next(error); + } + } } diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index a4c3f175ea..7c80fa819a 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -155,4 +155,88 @@ const generateSlackTextBlock = (message: string, link?: string, linkButtonText?: }; }; +/** + * Given an id of a channel, produces the slack ids of all the users in that channel. + * @param channelId the id of the channel + * @returns an array of strings of all the slack ids of the users in the given channel + */ +export const getUsersInChannel = async (channelId: string) => { + let members: string[] = []; + let cursor: string | undefined; + + try { + do { + const response = await slack.conversations.members({ + channel: channelId, + cursor, + limit: 200 + }); + + if (response.ok && response.members) { + members = members.concat(response.members); + cursor = response.response_metadata?.next_cursor; + } else { + throw new Error(`Failed to fetch members: ${response.error}`); + } + } while (cursor); + + return members; + } catch (error) { + throw new HttpException(500, 'Error getting members from a slack channel: ' + (error as any).data.error); + } +}; + +/** + * Given a slack channel id, prood.uces the name of the channel + * @param channelId the id of the slack channel + * @returns the name of the channel + */ +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'; + } catch (error) { + throw new HttpException(500, 'Error getting slack channel name: ' + (error as any).data.error); + } +}; + +/** + * 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) + */ +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'; + } catch (error) { + throw new HttpException(500, 'Error getting slack user name: ' + (error as any).data.error); + } +}; + +/** + * 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/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/20241220204512_home_page_updates/migration.sql similarity index 72% rename from src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql rename to src/backend/src/prisma/migrations/20241220204512_home_page_updates/migration.sql index c57afabd25..e20892547e 100644 --- a/src/backend/src/prisma/migrations/20241211195435_announcements_and_notifications/migration.sql +++ b/src/backend/src/prisma/migrations/20241220204512_home_page_updates/migration.sql @@ -1,9 +1,19 @@ +-- AlterTable +ALTER TABLE "Organization" ADD COLUMN "logoImageId" TEXT, +ADD COLUMN "slackWorkspaceId" 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 +23,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 +40,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 +56,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..3068c9558f 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") } @@ -878,6 +877,7 @@ model Organization { applyInterestImageId String? @unique exploreAsGuestImageId String? @unique logoImageId String? + slackWorkspaceId String? // Relation references wbsElements WBS_Element[] @@ -932,17 +932,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..ddd4e190cb 100644 --- a/src/backend/src/prisma/seed.ts +++ b/src/backend/src/prisma/seed.ts @@ -33,7 +33,7 @@ import { writeFileSync } from 'fs'; import WorkPackageTemplatesService from '../services/work-package-template.services'; import RecruitmentServices from '../services/recruitment.services'; import OrganizationsService from '../services/organizations.services'; -import NotificationsService from '../services/notifications.services'; +import AnnouncementService from '../services/announcement.service'; const prisma = new PrismaClient(); @@ -1895,7 +1895,15 @@ const performSeed: () => Promise = async () => { 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); + await AnnouncementService.createAnnouncement( + 'Welcome to Finishline!', + [regina.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', + ner.organizationId + ); }; performSeed() diff --git a/src/backend/src/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts index bea0984777..82b98b0a0b 100644 --- a/src/backend/src/routes/slack.routes.ts +++ b/src/backend/src/routes/slack.routes.ts @@ -1,8 +1,19 @@ -import express from 'express'; -import slackController from '../controllers/slack.controllers'; +import { createEventAdapter } from '@slack/events-api'; +import slackServices from '../services/slack.services'; +import OrganizationsService from '../services/organizations.services'; +import { getWorkspaceId } from '../integrations/slack'; -const slackRouter = express.Router(); +export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); -slackRouter.post('/', slackController.handleEvent); +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); + } +}); -export default slackRouter; +slackEvents.on('error', (error) => { + console.log(error.name); +}); diff --git a/src/backend/src/routes/users.routes.ts b/src/backend/src/routes/users.routes.ts index 34ae1a0136..96be49828c 100644 --- a/src/backend/src/routes/users.routes.ts +++ b/src/backend/src/routes/users.routes.ts @@ -54,6 +54,8 @@ userRouter.post( validateInputs, UsersController.getManyUserTasks ); -userRouter.get('/:userId/notifications', UsersController.getUserUnreadNotifications); +userRouter.get('/notifications/current-user', UsersController.getUserUnreadNotifications); +userRouter.get('/announcements/current-user', UsersController.getUserUnreadAnnouncements); +userRouter.post('/notifications/:notificationId/remove', UsersController.removeUserNotification); export default userRouter; diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index c4199a40a8..9ff1e7007e 100644 --- a/src/backend/src/services/announcement.service.ts +++ b/src/backend/src/services/announcement.service.ts @@ -5,6 +5,18 @@ import announcementTransformer from '../transformers/announcements.transformer'; import { NotFoundException } from '../utils/errors.utils'; export default class AnnouncementService { + /** + * Creates an announcement that is sent to users + * this data is populated from slack events + * @param text slack message text + * @param usersReceivedIds users to send announcements to + * @param dateCreated date created of slack message + * @param senderName name of user who sent slack message + * @param slackEventId id of slack event (provided by slack api) + * @param slackChannelName name of channel message was sent in + * @param organizationId id of organization of users + * @returns the created announcement + */ static async createAnnouncement( text: string, usersReceivedIds: string[], @@ -33,7 +45,7 @@ export default class AnnouncementService { return announcementTransformer(announcement); } - static async UpdateAnnouncement( + static async updateAnnouncement( text: string, usersReceivedIds: string[], dateCreated: Date, @@ -55,7 +67,7 @@ export default class AnnouncementService { data: { text, usersReceived: { - connect: usersReceivedIds.map((id) => ({ + set: usersReceivedIds.map((id) => ({ userId: id })) }, @@ -70,17 +82,26 @@ export default class AnnouncementService { return announcementTransformer(announcement); } - static async DeleteAnnouncement(slackEventId: string, organizationId: string): Promise { + 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/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/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/src/services/slack.services.ts b/src/backend/src/services/slack.services.ts index 20cbd3039f..991af8fc18 100644 --- a/src/backend/src/services/slack.services.ts +++ b/src/backend/src/services/slack.services.ts @@ -1,6 +1,289 @@ +import UsersService from './users.services'; +import { getChannelName, getUserName, getUsersInChannel } from '../integrations/slack'; +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; + client_msg_id: string; + text: string; + blocks: { + type: string; + block_id: string; + elements: any[]; + }[]; +} + +/** + * 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; + value?: string; + channel_id?: string; + timestamp?: number; + name?: string; + unicode?: string; + url?: string; + text?: string; + user_id?: string; + usergroup_id?: string; +} + export default class slackServices { - static async processEvent(req: any) { - //TODO: process request - console.log(req); + /** + * 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. + * @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 { + 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); + } 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; + } + + //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) => { + return UsersService.getUserSettings(user.userId); + }) + ); + + //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) { + 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) { + for (const element of richTextBlocks[0].elements[0].elements) { + messageText += await slackServices.blockToString(element); + userIdsToNotify = userIdsToNotify.concat( + await slackServices.blockToMentionedUsers(element, userSettings, event.channel) + ); + } + } else { + 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 await AnnouncementService.updateAnnouncement( + messageText, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } 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; + } } } diff --git a/src/backend/src/services/users.services.ts b/src/backend/src/services/users.services.ts index d9d37b259e..b5b64e5f7f 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,13 +571,69 @@ 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 unreadNotifications = await prisma.notification.findMany({ + where: { + users: { + some: { userId } + } + }, + ...getNotificationQueryArgs(organization.organizationId) + }); + + if (!unreadNotifications) throw new HttpException(404, 'User Unread Notifications Not Found'); + + return 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: { unreadNotifications: getNotificationQueryArgs(organization.organizationId) } + include: { unreadAnnouncements: getAnnouncementQueryArgs(organization.organizationId) } }); if (!requestedUser) throw new NotFoundException('User', userId); - return requestedUser.unreadNotifications.map(notificationTransformer); + 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/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/organization.test.ts b/src/backend/tests/mocked/organization.test.ts similarity index 91% rename from src/backend/tests/unmocked/organization.test.ts rename to src/backend/tests/mocked/organization.test.ts index 2c0affdff9..295b96031d 100644 --- a/src/backend/tests/unmocked/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); + }); + }); }); 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..5d3f3eea76 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,7 +120,9 @@ export const resetUsers = async () => { await prisma.milestone.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); await prisma.organization.deleteMany(); + await prisma.announcement.deleteMany(); await prisma.user.deleteMany(); + await prisma.announcement.deleteMany(); }; export const createFinanceTeamAndLead = async (organization?: Organization) => { @@ -454,3 +457,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 + } + ] + } + ] + }; +}; 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') + ); + }); +}); diff --git a/src/backend/tests/unmocked/users.test.ts b/src/backend/tests/unmocked/users.test.ts index 14d8b0bffe..bd0b934270 100644 --- a/src/backend/tests/unmocked/users.test.ts +++ b/src/backend/tests/unmocked/users.test.ts @@ -4,6 +4,7 @@ import { batmanAppAdmin } from '../test-data/users.test-data'; import UsersService from '../../src/services/users.services'; import { NotFoundException } from '../../src/utils/errors.utils'; import NotificationsService from '../../src/services/notifications.services'; +import AnnouncementService from '../../src/services/announcement.service'; describe('User Tests', () => { let orgId: string; @@ -51,13 +52,21 @@ describe('User Tests', () => { }); describe('Get Notifications', () => { - it('fails on invalid user id', async () => { - await expect(async () => await UsersService.getUserUnreadNotifications('1', 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'); }); + }); - it('Succeeds and gets user notifications', async () => { + describe('Remove Notifications', () => { + it('Succeeds and removes user notification', async () => { const testBatman = await createTestUser(batmanAppAdmin, orgId); await NotificationsService.sendNotifcationToUsers('test1', 'test1', [testBatman.userId], orgId); await NotificationsService.sendNotifcationToUsers('test2', 'test2', [testBatman.userId], orgId); @@ -67,6 +76,45 @@ describe('User Tests', () => { 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'); + }); + }); + + describe('Get Announcements', () => { + it('Succeeds and gets user announcements', async () => { + const testBatman = await createTestUser(batmanAppAdmin, orgId); + await AnnouncementService.createAnnouncement( + 'test1', + [testBatman.userId], + new Date(), + 'Thomas Emrax', + '1', + 'software', + organization.organizationId + ); + await AnnouncementService.createAnnouncement( + 'test2', + [testBatman.userId], + new Date(), + 'Superman', + '50', + 'mechanical', + organization.organizationId + ); + + const announcements = await UsersService.getUserUnreadAnnouncements(testBatman.userId, organization); + + expect(announcements).toHaveLength(2); + expect(announcements[0].text).toBe('test1'); + expect(announcements[1].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/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/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; } 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: