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 86470eb0a0..0f78b34b0d 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'; import announcementsRouter from './src/routes/announcements.routes'; import onboardingRouter from './src/routes/onboarding.routes'; import popUpsRouter from './src/routes/pop-up.routes'; @@ -43,6 +44,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 new file mode 100644 index 0000000000..e7336711d2 --- /dev/null +++ b/src/backend/src/controllers/slack.controllers.ts @@ -0,0 +1,18 @@ +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 relatedOrganization = organizations.find((org) => org.slackWorkspaceId === nerSlackWorkspaceId); + if (relatedOrganization) { + SlackServices.processMessageSent(event, relatedOrganization.organizationId); + } + } catch (error: unknown) { + console.log(error); + } + } +} diff --git a/src/backend/src/integrations/slack.ts b/src/backend/src/integrations/slack.ts index a4c3f175ea..6e855acae7 100644 --- a/src/backend/src/integrations/slack.ts +++ b/src/backend/src/integrations/slack.ts @@ -155,4 +155,79 @@ 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) { + 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 or undefined if it cannot be found + */ +export const getChannelName = async (channelId: string) => { + try { + const channelRes = await slack.conversations.info({ channel: channelId }); + return channelRes.channel?.name; + } catch (error) { + 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), undefined if cannot be found + */ +export const getUserName = async (userId: string) => { + try { + const userRes = await slack.users.info({ user: userId }); + return userRes.user?.profile?.display_name || userRes.user?.real_name; + } catch (error) { + return undefined; + } +}; + +/** + * Get the workspace id of the workspace this slack api is registered with + * @returns the id of the workspace + */ +export const getWorkspaceId = async () => { + 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/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/routes/slack.routes.ts b/src/backend/src/routes/slack.routes.ts new file mode 100644 index 0000000000..6878b176b1 --- /dev/null +++ b/src/backend/src/routes/slack.routes.ts @@ -0,0 +1,8 @@ +import { createEventAdapter } from '@slack/events-api'; +import SlackController from '../controllers/slack.controllers'; + +export const slackEvents = createEventAdapter(process.env.SLACK_SIGNING_SECRET || ''); + +slackEvents.on('message', SlackController.processMessageEvent); + +slackEvents.on('error', console.log); diff --git a/src/backend/src/services/announcement.service.ts b/src/backend/src/services/announcement.service.ts index ed528eb98e..6b5c758338 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 { /** @@ -10,7 +10,7 @@ export default class AnnouncementService { * 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 dateMessageSent 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 @@ -37,6 +37,47 @@ export default class AnnouncementService { dateMessageSent, senderName, slackEventId, + slackChannelName, + organizationId + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + + static async updateAnnouncement( + text: string, + usersReceivedIds: string[], + senderName: string, + slackEventId: string, + slackChannelName: string, + organizationId: string + ): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + + 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: { + text, + usersReceived: { + set: usersReceivedIds.map((id) => ({ + userId: id + })) + }, + slackEventId, + senderName, slackChannelName }, ...getAnnouncementQueryArgs(organizationId) @@ -45,6 +86,34 @@ export default class AnnouncementService { return announcementTransformer(announcement); } + static async deleteAnnouncement(slackEventId: string, organizationId: string): Promise { + const originalAnnouncement = await prisma.announcement.findUnique({ + where: { + slackEventId + } + }); + + if (!originalAnnouncement) throw new NotFoundException('Announcement', slackEventId); + + 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: { + dateDeleted: new Date(), + usersReceived: { + set: [] + } + }, + ...getAnnouncementQueryArgs(organizationId) + }); + + return announcementTransformer(announcement); + } + /** * Gets all of a user's unread announcements * @param userId id of the current user @@ -57,7 +126,8 @@ export default class AnnouncementService { dateDeleted: null, usersReceived: { some: { userId } - } + }, + organizationId }, ...getAnnouncementQueryArgs(organizationId) }); diff --git a/src/backend/src/services/organizations.services.ts b/src/backend/src/services/organizations.services.ts index 3ac4927097..53c118086c 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 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 new file mode 100644 index 0000000000..f6ece7ba85 --- /dev/null +++ b/src/backend/src/services/slack.services.ts @@ -0,0 +1,169 @@ +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'; +import { NotFoundException } from '../utils/errors.utils'; + +/** + * 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 { + /** + * 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 { + //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(1000 * 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; + return AnnouncementService.deleteAnnouncement(eventMessage.client_msg_id, organizationId); + 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 name of the user that sent the message from slack + let userName = (await getUserName(eventMessage.user)) ?? ''; + + //if slack could not produce the name of the user, look for their name in prisma + if (!userName) { + try { + const userWithThatSlackId = await prisma.user.findFirst({ where: { userSettings: { slackId: eventMessage.user } } }); + userName = `${userWithThatSlackId?.firstName} ${userWithThatSlackId?.lastName}`; + } catch { + 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 > 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, organizationId, 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; + } + + 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, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } 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, + userIdsToNotify, + dateCreated, + userName, + eventMessage.client_msg_id, + slackChannelName, + organizationId + ); + } +} diff --git a/src/backend/src/utils/auth.utils.ts b/src/backend/src/utils/auth.utils.ts index 8520d881e4..277010b66d 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 ( @@ -172,7 +174,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(); } diff --git a/src/backend/src/utils/errors.utils.ts b/src/backend/src/utils/errors.utils.ts index ce4c3415e1..9f6b5dc7ad 100644 --- a/src/backend/src/utils/errors.utils.ts +++ b/src/backend/src/utils/errors.utils.ts @@ -138,4 +138,5 @@ export type ExceptionObjectNames = | 'Organization' | 'Car' | 'Milestone' - | 'Faq'; + | 'Faq' + | 'Announcement'; diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index ecc264610b..4019c6d6db 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 { + 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,8 @@ 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'; +import UsersService from '../services/users.services'; interface SlackMessageThread { messageInfoId: string; @@ -470,3 +480,99 @@ 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 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, + organizationId: string, + channelId: string +): Promise => { + switch (block.type) { + case 'broadcast': + switch (block.range) { + case 'everyone': + 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); + const prismaIds: (string | undefined)[] = await Promise.all(slackIds.map(getUserIdFromSlackId)); + return prismaIds.filter((id): id is string => id !== undefined); + default: + return []; + } + case 'user': + 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 new file mode 100644 index 0000000000..703db97c1a --- /dev/null +++ b/src/backend/tests/integration/slackMessages.test.ts @@ -0,0 +1,399 @@ +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 prisma from '../../src/prisma/prisma'; + +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 () => { + await resetUsers(); + organization = await createTestOrganization(); + orgId = organization.organizationId; + 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 () => { + 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', '1', 'user name', 'id_1', [ + { type: 'text', text: 'test with ' }, + { type: 'broadcast', range: 'everyone' }, + { type: 'text', text: ' broadcast (@everyone)' } + ]), + orgId + ); + + expect(spy).toBeCalledTimes(1); + expect(spy).toBeCalledWith( + 'test with @everyone broadcast (@everyone)', + [batman.userId, superman.userId, wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @everyone broadcast (@everyone)'); + 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'); + expect(announcement?.usersReceived).toHaveLength(3); + }); + + 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', '1', '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(1000), + '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?.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'); + 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', '1', '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(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement?.text).toBe('test with @Slack User Name broadcast (@wonderwoman)'); + 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'); + 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', '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 } }, + { 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(1000), + '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?.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'); + 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', '1', 'user name', 'id_1', [{ type: 'text', text: 'just a text message' }]), + orgId + ); + + expect(spy).toBeCalledTimes(0); + + expect(announcement).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', '1', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + const announcement2 = await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_changed', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + 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'); + 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: '1', + channel_type: 'channel', + message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' }, + { type: 'text', text: ' added text' } + ]), + previous_message: createSlackMessageEvent('channel id', '1', 'user name', 'id_1', [ + { type: 'user', user_id: 'slackWW' } + ]) + }, + orgId + ); + + expect(updateSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(createSpy).toBeCalledWith( + '@Slack User Name added text', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + expect(announcement2?.text).toBe('@Slack User Name added text'); + 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'); + 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', '1', 'user name', 'id_1', [{ type: 'user', user_id: 'slackWW' }]), + orgId + ); + expect(createSpy).toBeCalledWith( + '@Slack User Name', + [wonderwoman.userId], + new Date(1000), + 'Slack User Name', + 'id_1', + 'Slack Channel Name', + orgId + ); + + await slackServices.processMessageSent( + { + type: 'message', + subtype: 'message_deleted', + channel: 'channel id', + event_ts: '1', + channel_type: 'channel', + previous_message: createSlackMessageEvent('channel id', '1', '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: '1', + 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 b399e44fe8..29070fee07 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; @@ -118,9 +119,10 @@ export const resetUsers = async () => { await prisma.wBS_Element.deleteMany(); await prisma.milestone.deleteMany(); await prisma.frequentlyAskedQuestion.deleteMany(); + await prisma.announcement.deleteMany(); + await prisma.popUp.deleteMany(); await prisma.organization.deleteMany(); await prisma.user.deleteMany(); - await prisma.announcement.deleteMany(); }; export const createFinanceTeamAndLead = async (organization?: Organization) => { @@ -455,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/unit/announcements.test.ts b/src/backend/tests/unit/announcements.test.ts new file mode 100644 index 0000000000..8f701ec41d --- /dev/null +++ b/src/backend/tests/unit/announcements.test.ts @@ -0,0 +1,223 @@ +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 { 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 () => { + await resetUsers(); + 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?.dateMessageSent).toStrictEqual(new Date(1000000000000)); + expect(announcement?.slackEventId).toBe('slack id'); + expect(announcement?.slackChannelName).toBe('channel name'); + expect(announcement?.dateDeleted).toBeUndefined(); + + const smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + const bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + const wwAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(wonderwoman.userId, orgId); + + 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]?.dateMessageSent).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 AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + let bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + let wwAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(wonderwoman.userId, orgId); + + expect(smAnnouncements).toHaveLength(1); + expect(bmAnnouncements).toHaveLength(1); + expect(wwAnnouncements).toHaveLength(0); + + const updatedAnnouncement = await AnnouncementService.updateAnnouncement( + 'new text', + [batman.userId, wonderwoman.userId], + 'sender name', + 'slack id', + 'channel name', + orgId + ); + + smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + wwAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(wonderwoman.userId, orgId); + + 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], + '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 AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + let bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + + expect(smAnnouncements).toHaveLength(1); + expect(bmAnnouncements).toHaveLength(1); + + const deletedAnnouncement = await AnnouncementService.deleteAnnouncement('slack id', orgId); + + smAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(superman.userId, orgId); + bmAnnouncements = await AnnouncementService.getUserUnreadAnnouncements(batman.userId, orgId); + + 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') + ); + }); + + describe('Get Announcements', () => { + it('Succeeds and gets user announcements', 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'); + }); + }); + + 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'); + }); + }); +}); 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/organization.test.ts b/src/backend/tests/unit/organization.test.ts similarity index 100% rename from src/backend/tests/unmocked/organization.test.ts rename to src/backend/tests/unit/organization.test.ts diff --git a/src/backend/tests/unmocked/pop-up.test.ts b/src/backend/tests/unit/pop-up.test.ts similarity index 100% rename from src/backend/tests/unmocked/pop-up.test.ts rename to src/backend/tests/unit/pop-up.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 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: