diff --git a/src/backend/src/controllers/calendar.controllers.ts b/src/backend/src/controllers/calendar.controllers.ts index d75cad9b40..1e12d35b23 100644 --- a/src/backend/src/controllers/calendar.controllers.ts +++ b/src/backend/src/controllers/calendar.controllers.ts @@ -279,7 +279,8 @@ export default class CalendarController { questionDocumentLink, location, zoomLink, - description + description, + mention } = req.body; const parsedScheduleSlots = scheduleSlots.map((slot: any) => ({ @@ -307,7 +308,8 @@ export default class CalendarController { questionDocumentLink, location, zoomLink, - description + description, + mention ); res.status(200).json(event); } catch (error: unknown) { @@ -333,7 +335,8 @@ export default class CalendarController { questionDocumentLink, location, zoomLink, - description + description, + mention } = req.body; const event = await CalendarService.editEvent( @@ -353,7 +356,8 @@ export default class CalendarController { questionDocumentLink, location, zoomLink, - description + description, + mention ); res.status(200).json(event); } catch (error: unknown) { diff --git a/src/backend/src/prisma/migrations/20260513143827_add_mention_to_event/migration.sql b/src/backend/src/prisma/migrations/20260513143827_add_mention_to_event/migration.sql new file mode 100644 index 0000000000..1369d6e8be --- /dev/null +++ b/src/backend/src/prisma/migrations/20260513143827_add_mention_to_event/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "Slack_Mention_Type" AS ENUM ('USER', 'CHANNEL'); + +-- AlterTable +ALTER TABLE "Event" ADD COLUMN "mention" "Slack_Mention_Type" NOT NULL DEFAULT 'USER'; diff --git a/src/backend/src/prisma/schema.prisma b/src/backend/src/prisma/schema.prisma index a757bbaa43..af5fb82ef0 100644 --- a/src/backend/src/prisma/schema.prisma +++ b/src/backend/src/prisma/schema.prisma @@ -101,6 +101,11 @@ enum Event_Status { DONE } +enum Slack_Mention_Type { + USER + CHANNEL +} + enum Graph_Type { PROJECT_BUDGET_BY_PROJECT PROJECT_BUDGET_BY_TEAM @@ -1181,6 +1186,7 @@ model Event { description String? notificationSlackThreads Message_Info[] calendarEventIds String[] + mention Slack_Mention_Type @default(USER) } model Calendar { diff --git a/src/backend/src/routes/calendar.routes.ts b/src/backend/src/routes/calendar.routes.ts index 571ae2afaf..ffce61e2e3 100644 --- a/src/backend/src/routes/calendar.routes.ts +++ b/src/backend/src/routes/calendar.routes.ts @@ -119,6 +119,7 @@ calendarRouter.post( isDate(body('scheduleSlots.*.startTime')), isDate(body('scheduleSlots.*.endTime')), body('scheduleSlots.*.allDay').isBoolean(), + body('SlackMentionType').isIn(['USER', 'CHANNEL']), validateInputs, CalendarController.createEvent ); @@ -147,6 +148,7 @@ calendarRouter.post( nonEmptyString(body('documents.*.googleFileId')), body('questionDocumentLink').optional().isString(), body('description').optional().isString(), + body('SlackMentionType').isIn(['USER', 'CHANNEL']), validateInputs, CalendarController.editEvent ); diff --git a/src/backend/src/services/calendar.services.ts b/src/backend/src/services/calendar.services.ts index d625ca051a..3bec5666ab 100644 --- a/src/backend/src/services/calendar.services.ts +++ b/src/backend/src/services/calendar.services.ts @@ -16,7 +16,8 @@ import { ScheduleSlot, notGuest, isSameDay, - EventInstance + EventInstance, + SlackMentionType } from 'shared'; import { getCalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; import { getEventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; @@ -67,7 +68,7 @@ import { updateUserAvailability, areUsersinList } from '../utils/users.utils.js'; -import { Conflict_Status, Event_Status, Organization, Team } from '@prisma/client'; +import { Conflict_Status, Event_Status, Organization, Team, Slack_Mention_Type } from '@prisma/client'; export default class CalendarService { /** @@ -270,7 +271,8 @@ export default class CalendarService { questionDocumentLink?: string, location?: string, zoomLink?: string, - description?: string + description?: string, + mention?: SlackMentionType ): Promise { // Validate eventTypeId const foundEventType = await prisma.event_Type.findUnique({ @@ -461,7 +463,8 @@ export default class CalendarService { location, zoomLink, questionDocumentLink, - description + description, + mention: mention === SlackMentionType.CHANNEL ? Slack_Mention_Type.CHANNEL : Slack_Mention_Type.USER }, ...getEventQueryArgs(organization.organizationId) }); @@ -541,7 +544,8 @@ export default class CalendarService { createdEvent, submitter, workPackageNames, - organization.name + organization.name, + { memberSlackIds: memberUserSettings.map((s) => s.slackId).filter((id): id is string => !!id) } ); } @@ -591,7 +595,8 @@ export default class CalendarService { questionDocumentLink?: string, location?: string, zoomLink?: string, - description?: string + description?: string, + mention?: SlackMentionType ): Promise { // validate eventId const foundEvent = await prisma.event.findUnique({ @@ -774,7 +779,10 @@ export default class CalendarService { location, zoomLink, questionDocumentLink, - description + description, + ...(mention !== undefined && { + mention: mention === SlackMentionType.CHANNEL ? Slack_Mention_Type.CHANNEL : Slack_Mention_Type.USER + }) }, ...getEventQueryArgs(organization.organizationId) }); diff --git a/src/backend/src/transformers/calendar.transformer.ts b/src/backend/src/transformers/calendar.transformer.ts index d1317748a8..8512c0a0b7 100644 --- a/src/backend/src/transformers/calendar.transformer.ts +++ b/src/backend/src/transformers/calendar.transformer.ts @@ -2,7 +2,8 @@ import { Prisma, DayOfWeek as PrismaDayOfWeek, Event_Status as PrismaEventStatus, - Conflict_Status as PrismaConflictStatus + Conflict_Status as PrismaConflictStatus, + Slack_Mention_Type as PrismaSlackMentionType } from '@prisma/client'; import { Machinery, @@ -17,7 +18,8 @@ import { DayOfWeek, ConflictStatus, Document, - EventWithMembers + EventWithMembers, + SlackMentionType } from 'shared'; import { CalendarQueryArgs } from '../prisma-query-args/calendar.query-args.js'; import { EventTypeQueryArgs } from '../prisma-query-args/event-type.query-args.js'; @@ -134,7 +136,8 @@ export const eventTransformer = (event: Prisma.EventGetPayload): questionDocumentLink: event.questionDocumentLink ?? undefined, description: event.description ?? undefined, status: eventStatusTransformer(event.status), - initialDateScheduled: event.initialDateScheduled ?? undefined + initialDateScheduled: event.initialDateScheduled ?? undefined, + mention: slackMentionTypeTransformer(event.mention) }; }; @@ -178,7 +181,8 @@ export const eventWithMembersTransformer = (event: Prisma.EventGetPayload { + const mapping: Record = { + USER: SlackMentionType.USER, + CHANNEL: SlackMentionType.CHANNEL + }; + return mapping[mention]; +}; diff --git a/src/backend/src/utils/slack.utils.ts b/src/backend/src/utils/slack.utils.ts index b85a4ca721..835aa817f6 100644 --- a/src/backend/src/utils/slack.utils.ts +++ b/src/backend/src/utils/slack.utils.ts @@ -7,7 +7,8 @@ import { CreateSponsorTask, User, Event, - formatForSlack + formatForSlack, + SlackMentionType } from 'shared'; import { Account_Code, Reimbursement_Product_Other_Reason, Sponsor_Task } from '@prisma/client'; import { @@ -383,35 +384,48 @@ export const sendAndGetSlackCRNotifications = async ( return notifications; }; +export const buildSlackMentionPrefix = (mention: SlackMentionType, memberSlackIds: string[]): string => { + if (mention === SlackMentionType.CHANNEL) return ' '; + if (memberSlackIds.length > 0) return `${memberSlackIds.map((id) => `<@${id}>`).join(' ')} `; + return ''; +}; + export const sendSlackEventNotification = async ( team: Team, message: string ): Promise<{ channelId: string; ts: string }[]> => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return []; // don't send msgs unless in prod const msgs: { channelId: string; ts: string }[] = []; - const fullMsg = `${message}`; const fullLink = `https://finishlinebyner.com/calendar`; const btnText = `View Calendar`; - const notification = await sendMessage(team.slackId, fullMsg, fullLink, btnText); + const notification = await sendMessage(team.slackId, message, fullLink, btnText); if (notification) msgs.push(notification); return msgs; }; +export interface EventNotificationOptions { + memberSlackIds?: string[]; +} + export const sendSlackEventNotifications = async ( teams: Team[], event: Event, submitter: User, workPackageName: string, - projectName: string + projectName: string, + options: EventNotificationOptions = {} ) => { if (process.env.NODE_ENV !== 'production' && !DEV_TESTING_OVERRIDE) return []; // don't send msgs unless in prod const notifications: { channelId: string; ts: string }[] = []; + + const mentionPrefix = buildSlackMentionPrefix(event.mention, options.memberSlackIds ?? []); + let message; if (workPackageName) { - message = `:spiral_calendar_pad: ${event.title} for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; + message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} for *${workPackageName}* is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; } else { - message = `:spiral_calendar_pad: ${event.title} is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; + message = `${mentionPrefix}:spiral_calendar_pad: ${event.title} is being scheduled by ${submitter.firstName} ${submitter.lastName} in project ${projectName}`; } const completion: Promise[] = teams.map(async (team) => { @@ -487,9 +501,14 @@ export const sendEventScheduledSlackNotif = async (threads: SlackMessageThread[] const location = zoomLink && inPersonLocation ? `${inPersonLocation} and ${zoomLink}` : inPersonLocation || zoomLink || ''; + const allMembers = [...event.requiredMembers, ...event.optionalMembers]; + const resolvedSlackIds = await Promise.all(allMembers.map((m) => getUserSlackId(m.userId))); + const validSlackIds = resolvedSlackIds.filter((id): id is string => !!id); + const mentionPrefix = buildSlackMentionPrefix(event.mention, validSlackIds); + const msg = `:spiral_calendar_pad: ${event.title} for *${drName}* has been scheduled for *${drTime}* ${location} by ${drSubmitter}`; const docLink = event.questionDocumentLink ? `<${event.questionDocumentLink}|Doc Link>` : ''; - const threadMsg = `This event has been Scheduled! \n` + docLink; + const threadMsg = `${mentionPrefix}This event has been Scheduled! \n` + docLink; if (threads && threads.length !== 0) { const msgs = threads.map((thread) => editMessage(thread.channelId, thread.timestamp, msg)); diff --git a/src/backend/tests/unit/calendar.test.ts b/src/backend/tests/unit/calendar.test.ts index 50a6b39623..f5385617d4 100644 --- a/src/backend/tests/unit/calendar.test.ts +++ b/src/backend/tests/unit/calendar.test.ts @@ -17,7 +17,7 @@ import { } from '../test-data/users.test-data'; import { createTestOrganization, createTestUser, resetUsers } from '../test-utils'; import prisma from '../../src/prisma/prisma'; -import { EventType, Machinery, ScheduleSlotCreateArgs, Shop, Event } from 'shared'; +import { EventType, Machinery, ScheduleSlotCreateArgs, Shop, Event, SlackMentionType } from 'shared'; describe('Calendar Tests', () => { let orgId: string; @@ -1266,6 +1266,71 @@ describe('Calendar Tests', () => { ) ).rejects.toThrow(new NotFoundException('Machinery', deletedMachinery.machineryId)); }); + + it('defaults mention to USER when not specified', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const result = await CalendarService.createEvent( + adminUser, + 'User Mention Event', + eventType.eventTypeId, + organization, + [member.userId], + [], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, // initialDateScheduled + undefined, // teamTypeId + undefined, // questionDocumentLink + 'Conference Room A', + undefined, // zoomLink + 'Test description' + ); + + expect(result.mention).toBe(SlackMentionType.USER); + }); + + it('persists CHANNEL mention type when specified', async () => { + const scheduleSlots = [ + { + startTime: new Date('2025-10-13T09:00:00Z'), + endTime: new Date('2025-10-13T10:00:00Z'), + allDay: false + } + ]; + + const result = await CalendarService.createEvent( + adminUser, + 'Channel Mention Event', + eventType.eventTypeId, + organization, + [member.userId], + [], + [], + [shop.shopId], + [machinery.machineryId], + [], + scheduleSlots, + undefined, // initialDateScheduled + undefined, // teamTypeId + undefined, // questionDocumentLink + 'Conference Room A', + undefined, // zoomLink + 'Test description', + SlackMentionType.CHANNEL + ); + + expect(result.mention).toBe(SlackMentionType.CHANNEL); + }); }); describe('Get Events', () => { @@ -1899,6 +1964,34 @@ describe('Calendar Tests', () => { expect(result.zoomLink).toBe('https://zoom.us/updated'); expect(result.description).toBe('Updated description'); }); + + it('updates mention from USER to CHANNEL', async () => { + // event is created with default USER mention in beforeEach + expect(event.mention).toBe(SlackMentionType.USER); + + const result = await CalendarService.editEvent( + adminUser, + event.eventId, + event.title, + organization, + [member.userId], + [adminUser.userId], + Event_Status.SCHEDULED, + [], + [shop.shopId], + [machinery.machineryId], + [], + [], + undefined, + undefined, + undefined, + undefined, + undefined, + SlackMentionType.CHANNEL + ); + + expect(result.mention).toBe(SlackMentionType.CHANNEL); + }); }); describe('Delete Event', () => { diff --git a/src/frontend/src/hooks/calendar.hooks.ts b/src/frontend/src/hooks/calendar.hooks.ts index 5cfd25848a..df029439fe 100644 --- a/src/frontend/src/hooks/calendar.hooks.ts +++ b/src/frontend/src/hooks/calendar.hooks.ts @@ -12,7 +12,8 @@ import { ScheduleSlotCreateArgs, EventWithMembers, ScheduleSlot, - EventInstance + EventInstance, + SlackMentionType } from 'shared'; import { getAllShops, @@ -82,6 +83,7 @@ export interface EventCreateArgs { description?: string; initialDateScheduled: Date; scheduleSlots: ScheduleSlotCreateArgs[]; + mention?: SlackMentionType; } export interface EditEventArgs { @@ -99,6 +101,7 @@ export interface EditEventArgs { documents: Array<{ name: string; googleFileId: string }>; questionDocumentLink?: string; description?: string; + mention?: SlackMentionType; } export interface EditScheduleSlotArgs { diff --git a/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx index 05831ee7e4..96a1610e05 100644 --- a/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/CreateEventModal.tsx @@ -32,7 +32,7 @@ const CreateEventModal: React.FC = ({ const handleSubmit = async (payload: EventPayload) => { try { - const { documentFiles, createScheduleSlotArgs, initialDateScheduled, ...eventData } = payload; + const { documentFiles, createScheduleSlotArgs, initialDateScheduled, mention, ...eventData } = payload; const scheduleSlots: Array<{ startTime: Date; @@ -82,7 +82,8 @@ const CreateEventModal: React.FC = ({ ...eventData, initialDateScheduled: initialDateScheduled ?? new Date(), scheduleSlots, - documentIds: [] + documentIds: [], + mention }; const createdEvent = await createEvent(createArgs); diff --git a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx index 18b2ceaece..870319afba 100644 --- a/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx +++ b/src/frontend/src/pages/CalendarPage/Components/EventModal.tsx @@ -13,7 +13,10 @@ import { Button, Stack, Checkbox, - FormControlLabel + FormControlLabel, + ToggleButtonGroup, + ToggleButton, + useTheme } from '@mui/material'; import { DatePicker, TimePicker } from '@mui/x-date-pickers'; import { Controller, useForm } from 'react-hook-form'; @@ -28,7 +31,8 @@ import { isHead, MAX_FILE_SIZE, getNextSevenDays, - getDay + getDay, + SlackMentionType } from 'shared'; import { useToast } from '../../../hooks/toasts.hooks'; import { useAllMembers, useCurrentUser } from '../../../hooks/users.hooks'; @@ -79,6 +83,7 @@ export interface EventFormValues { recurrenceNumber: number; days: DayOfWeek[]; selectedScheduleSlotId?: string; + mention: SlackMentionType; } export interface EventPayload { @@ -96,6 +101,7 @@ export interface EventPayload { documentFiles: EventDocumentUploadArgs[]; questionDocumentLink?: string; description?: string; + mention: SlackMentionType; // If the event type requires confirmation, only intialDateScheduled will be populated. If not, // scheduleSlots will be populated based on if the event is being editted or created initialDateScheduled?: Date; @@ -144,7 +150,8 @@ const schema = yup.object().shape({ allDay: yup.boolean().required(), recurrenceNumber: yup.number().min(0).required('Recurrence is required'), days: yup.array().of(yup.mixed().required()).default([]), - selectedScheduleSlotId: yup.string().optional() + selectedScheduleSlotId: yup.string().optional(), + mention: yup.mixed().required().default(SlackMentionType.USER) }); export interface BaseEventModalProps { @@ -221,6 +228,7 @@ const EventModal: React.FC = ({ eventId, actionsLeftChildren }) => { + const theme = useTheme(); const toast = useToast(); const user = useCurrentUser(); const [datePickerOpen, setDatePickerOpen] = useState(false); @@ -288,7 +296,8 @@ const EventModal: React.FC = ({ allDay: initialValues?.allDay ?? false, recurrenceNumber: 0, days: [], - selectedScheduleSlotId: initialValues?.selectedScheduleSlotId + selectedScheduleSlotId: initialValues?.selectedScheduleSlotId, + mention: initialValues?.mention ?? SlackMentionType.USER }; }, [initialValues, defaultDate, defaultStartTime, defaultEndTime]); @@ -506,7 +515,8 @@ const EventModal: React.FC = ({ workPackageIds: data.workPackageIds, documentFiles: data.documentFiles, questionDocumentLink: data.questionDocumentLink, - description: data.description + description: data.description, + mention: data.mention }; // If the event requires confirmation, only populate initialDateScheduled @@ -1191,6 +1201,53 @@ const EventModal: React.FC = ({ )} + + {/* Slack Mention Type Toggle */} + {selectedEventType.sendSlackNotifications && ( + + ( + { + if (val) onChange(val); + }} + size="small" + sx={{ + '& .MuiToggleButton-root': { + borderRadius: 0, + textTransform: 'none', + py: 0.55, + px: 1.1, + borderColor: theme.palette.divider, + color: theme.palette.text.primary, + '&.Mui-selected': { + bgcolor: theme.palette.primary.main, + color: 'black', + '&:hover': { bgcolor: '#ff0000', color: 'white' } + }, + '&:hover': { bgcolor: theme.palette.action.hover } + }, + '& .MuiToggleButton-root:first-of-type': { + borderTopLeftRadius: 8, + borderBottomLeftRadius: 8 + }, + '& .MuiToggleButton-root:last-of-type': { + borderTopRightRadius: 8, + borderBottomRightRadius: 8 + } + }} + > + @user + @channel + + )} + /> + + )} )} {/* Required Members Section */} diff --git a/src/frontend/src/utils/calendar.utils.ts b/src/frontend/src/utils/calendar.utils.ts index 474efc459d..c2ccd32b1a 100644 --- a/src/frontend/src/utils/calendar.utils.ts +++ b/src/frontend/src/utils/calendar.utils.ts @@ -234,6 +234,7 @@ export const convertEventToFormValues = (event: EventInstance): Partial & @@ -255,6 +261,7 @@ export interface EventWithMembers { description?: string; status: EventStatus; initialDateScheduled?: Date; + mention: SlackMentionType; } export interface TeamType {