Skip to content

Commit a6bd95b

Browse files
committed
Design Review Workflow Fixes
1 parent 2b5458b commit a6bd95b

16 files changed

Lines changed: 571 additions & 197 deletions

File tree

src/backend/src/controllers/calendar.controllers.ts

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -484,6 +484,25 @@ export default class CalendarController {
484484
}
485485
}
486486

487+
// Schedule an event by adding a schedule slot and changing status to SCHEDULED
488+
static async scheduleEvent(req: Request, res: Response, next: NextFunction) {
489+
try {
490+
const { eventId } = req.params as Record<string, string>;
491+
const { startTime, endTime } = req.body;
492+
493+
const updatedEvent = await CalendarService.scheduleEvent(
494+
req.currentUser,
495+
eventId,
496+
new Date(startTime),
497+
new Date(endTime),
498+
req.organization
499+
);
500+
res.status(200).json(updatedEvent);
501+
} catch (error: unknown) {
502+
next(error);
503+
}
504+
}
505+
487506
//overall filtering for events
488507
static async getFilteredEvents(req: Request, res: Response, next: NextFunction) {
489508
try {

src/backend/src/routes/calendar.routes.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,14 @@ calendarRouter.post(
197197
CalendarController.setStatus
198198
);
199199

200+
calendarRouter.post(
201+
'/event/:eventId/schedule',
202+
isDate(body('startTime')),
203+
isDate(body('endTime')),
204+
validateInputs,
205+
CalendarController.scheduleEvent
206+
);
207+
200208
calendarRouter.post('/event/:eventId/delete', CalendarController.deleteEvent);
201209

202210
calendarRouter.get('/event/:eventId/conflict', CalendarController.getConflictingEvent);

src/backend/src/services/calendar.services.ts

Lines changed: 89 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -766,15 +766,6 @@ export default class CalendarService {
766766

767767
let newStatus = status;
768768

769-
// If all required members are confirmed, set the status to SCHEDULED
770-
const allRequiredMembersConfirmed = updatedRequiredMembers.every((member) =>
771-
foundEvent.confirmedMembers.map((user: { userId: string }) => user.userId).includes(member.userId)
772-
);
773-
774-
if (status === Event_Status.CONFIRMED && allRequiredMembersConfirmed) {
775-
newStatus = Event_Status.SCHEDULED;
776-
}
777-
778769
// Update the event with new data (excluding schedule slots)
779770
const updatedEvent = await prisma.event.update({
780771
where: { eventId },
@@ -1407,6 +1398,95 @@ export default class CalendarService {
14071398
return eventTransformer(event);
14081399
}
14091400

1401+
/**
1402+
* Schedules an event by adding a schedule slot and changing the status to SCHEDULED.
1403+
* Only the event creator can schedule the event.
1404+
*
1405+
* @param submitter The user submitting the request.
1406+
* @param eventId The id of the event to schedule.
1407+
* @param startTime The start time of the scheduled slot.
1408+
* @param endTime The end time of the scheduled slot.
1409+
* @param organization The organization context.
1410+
*
1411+
* @returns The updated event with the new schedule slot.
1412+
*
1413+
* @throws NotFoundException If the event is not found.
1414+
* @throws DeletedException If the event has been deleted.
1415+
* @throws AccessDeniedException If the user is not the event creator or an admin.
1416+
* @throws HttpException If the event is not in CONFIRMED status.
1417+
*/
1418+
static async scheduleEvent(
1419+
submitter: User,
1420+
eventId: string,
1421+
startTime: Date,
1422+
endTime: Date,
1423+
organization: Organization
1424+
): Promise<Event> {
1425+
const event = await prisma.event.findUnique({
1426+
where: { eventId },
1427+
...getEventQueryArgs(organization.organizationId)
1428+
});
1429+
1430+
if (!event) throw new NotFoundException('Event', eventId);
1431+
if (event.dateDeleted) throw new DeletedException('Event', eventId);
1432+
1433+
// Cannot schedule an already scheduled event
1434+
if (event.status === Event_Status.SCHEDULED) {
1435+
throw new HttpException(400, 'Event is already scheduled');
1436+
}
1437+
1438+
// Only the event creator can schedule the event
1439+
if (
1440+
submitter.userId !== event.userCreatedId &&
1441+
!(await userHasPermission(submitter.userId, organization.organizationId, isAdmin))
1442+
) {
1443+
throw new AccessDeniedException('Only the event creator and admins can schedule the event');
1444+
}
1445+
1446+
// Check for conflicts with the new time
1447+
const newSlotData: ScheduleSlotCreateArgs = {
1448+
startTime,
1449+
endTime,
1450+
allDay: false
1451+
};
1452+
1453+
const { hasConflict, conflictingEvent } = await checkEventConflicts(
1454+
[newSlotData],
1455+
organization,
1456+
event.location ?? undefined,
1457+
event.eventId
1458+
);
1459+
1460+
// Update the event with a new schedule slot and change status to SCHEDULED
1461+
const updatedEvent = await prisma.event.update({
1462+
where: { eventId },
1463+
data: {
1464+
status: Event_Status.SCHEDULED,
1465+
scheduledTimes: {
1466+
create: {
1467+
startTime,
1468+
endTime,
1469+
allDay: false
1470+
}
1471+
},
1472+
approved: hasConflict ? Conflict_Status.PENDING : event.approved,
1473+
approvalRequiredFromUserId: hasConflict ? conflictingEvent?.userCreated.userId : event.approvalRequiredFromUserId
1474+
},
1475+
...getEventQueryArgs(organization.organizationId)
1476+
});
1477+
1478+
const { eventTypeId } = updatedEvent;
1479+
const foundEventType = await prisma.event_Type.findUnique({
1480+
where: { eventTypeId }
1481+
});
1482+
1483+
if (foundEventType?.sendSlackNotifications) {
1484+
await sendEventScheduledSlackNotif(updatedEvent.notificationSlackThreads, eventTransformer(updatedEvent));
1485+
}
1486+
1487+
return eventTransformer(updatedEvent);
1488+
}
1489+
14101490
/**
14111491
* Sets the status of an event, only admin or the user who created the event can set the status.
14121492
* @param user the user trying to set the status

src/backend/src/utils/slack.utils.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -253,8 +253,8 @@ export const sendSlackEventConfirmNotification = async (
253253
if (!isProduction && !DEV_TESTING_OVERRIDE) return; // don't send msgs unless in prod
254254
const msg = `You have been invited to the ${eventName} Design Review in project ${projectName}!`;
255255
const fullLink = isProduction
256-
? `https://finishlinebyner.com/settings/preferences?eventId=${eventId}`
257-
: `http://localhost:3000/settings/preferences?eventId=${eventId}`;
256+
? `https://finishlinebyner.com/calendar/event/${eventId}`
257+
: `http://localhost:3000/calendar/event/${eventId}`;
258258
const linkButtonText = 'Confirm Availability';
259259

260260
try {

src/frontend/src/apis/calendar.api.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ import {
1212
FilterArgs,
1313
ScheduleSlot
1414
} from 'shared';
15-
import { eventTransformer } from './transformers/calendar.transformer';
15+
import { eventTransformer, eventWithMembersTransformer } from './transformers/calendar.transformer';
1616
import { EditEventArgs, EditScheduleSlotArgs, EventCreateArgs } from '../hooks/calendar.hooks';
1717

1818
export const getAllCalendars = () => {
@@ -129,7 +129,7 @@ export const getSingleEvent = async (id: string) => {
129129

130130
export const getSingleEventWithMembers = async (id: string) => {
131131
return axios.get(apiUrls.calendarGetSingleEventWithMembers(id), {
132-
transformResponse: (data) => eventTransformer(JSON.parse(data))
132+
transformResponse: (data) => eventWithMembersTransformer(JSON.parse(data))
133133
});
134134
};
135135

@@ -251,3 +251,15 @@ export const downloadDocumentPdf = async (fileId: string): Promise<Blob> => {
251251
});
252252
return response.data; // response.data is already a Blob
253253
};
254+
255+
/**
256+
* Schedules an event by adding a schedule slot and changing status to SCHEDULED
257+
*
258+
* @param eventId The id of the event to schedule
259+
* @param payload The start and end times for the new schedule slot
260+
*/
261+
export const scheduleEvent = async (eventId: string, payload: { startTime: Date; endTime: Date }) => {
262+
return axios.post<Event>(apiUrls.calendarScheduleEvent(eventId), payload, {
263+
transformResponse: (data) => eventTransformer(JSON.parse(data))
264+
});
265+
};

src/frontend/src/hooks/calendar.hooks.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,7 +47,8 @@ import {
4747
postEditScheduleSlot,
4848
getSingleEventWithMembers,
4949
previewScheduleSlotRecurringEdits,
50-
postDeleteScheduleSlot
50+
postDeleteScheduleSlot,
51+
scheduleEvent
5152
} from '../apis/calendar.api';
5253
import { useCurrentUser } from './users.hooks';
5354
import { PDFDocument } from 'pdf-lib';
@@ -565,6 +566,29 @@ export const useDenyEvent = (id: string) => {
565566
);
566567
};
567568

569+
/**
570+
* Hook to schedule an event by adding a schedule slot and changing status to SCHEDULED.
571+
* Only the event creator can schedule the event.
572+
*/
573+
export const useScheduleEvent = (eventId: string) => {
574+
const queryClient = useQueryClient();
575+
return useMutation<Event, Error, { startTime: Date; endTime: Date }>(
576+
['events', 'schedule', eventId],
577+
async (payload) => {
578+
const { data } = await scheduleEvent(eventId, payload);
579+
return data;
580+
},
581+
{
582+
onSuccess: () => {
583+
queryClient.invalidateQueries(['events', eventId]);
584+
queryClient.invalidateQueries(['events', eventId, 'with-members']);
585+
queryClient.invalidateQueries(['filter-events']);
586+
queryClient.invalidateQueries(EVENT_KEY);
587+
}
588+
}
589+
);
590+
};
591+
568592
/**
569593
* Custom React Hook to upload a new document.
570594
*/

src/frontend/src/pages/CalendarPage/AvailabilityScheduleView.tsx

Lines changed: 62 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { Table, TableBody, TableCell, TableContainer, TableHead, TableRow, Typography } from '@mui/material';
2-
import { Availability, Event, getDayOfWeek, getNextSevenDays, User } from 'shared';
2+
import { Availability, Event, EventWithMembers, getDayOfWeek, getNextSevenDays, User } from 'shared';
33
import React, { useState } from 'react';
44
import { enumToArray, getBackgroundColor, NUMBER_OF_TIME_SLOTS, REVIEW_TIMES } from '../../utils/design-review.utils';
55
import { datePipe } from '../../utils/pipes';
@@ -11,8 +11,10 @@ interface AvailabilityScheduleViewProps {
1111
usersToAvailabilities: Map<User, Availability[]>;
1212
setCurrentAvailableUsers: (val: User[]) => void;
1313
setCurrentUnavailableUsers: (val: User[]) => void;
14-
event: Event;
14+
setCurrentHoveredSlot?: (slot: { day: Date; startHour: number; endHour: number } | null) => void;
15+
event: Event | EventWithMembers;
1516
displayDate?: Date;
17+
onSlotScheduleClick?: (day: Date | null, startHour: number, endHour: number) => void;
1618
}
1719

1820
const AvailabilityScheduleView: React.FC<AvailabilityScheduleViewProps> = ({
@@ -21,24 +23,56 @@ const AvailabilityScheduleView: React.FC<AvailabilityScheduleViewProps> = ({
2123
usersToAvailabilities,
2224
setCurrentAvailableUsers,
2325
setCurrentUnavailableUsers,
26+
setCurrentHoveredSlot,
2427
event,
25-
displayDate
28+
displayDate,
29+
onSlotScheduleClick
2630
}) => {
2731
const totalUsers = usersToAvailabilities.size;
2832
const [selectedTimeslot, setSelectedTimeslot] = useState<number | null>(null);
2933
// Use displayDate if provided, otherwise fall back to event's initial date
3034
const initialDate = displayDate || event.initialDateScheduled || new Date();
3135
const potentialDays = getNextSevenDays(initialDate);
3236

33-
const handleTimeslotClick = (index: number, _day: Date) => {
37+
// Handle hover - updates the sidebar with available/unavailable users and slot info
38+
const handleTimeslotHover = (index: number, day: Date, timeIndex: number) => {
39+
setCurrentAvailableUsers(availableUsers.get(index) || []);
40+
setCurrentUnavailableUsers(unavailableUsers.get(index) || []);
41+
if (setCurrentHoveredSlot) {
42+
const startHour = 10 + timeIndex;
43+
const endHour = 11 + timeIndex;
44+
setCurrentHoveredSlot({ day, startHour, endHour });
45+
}
46+
};
47+
48+
// Handle mouse leave - clears the hover state and shows selected slot's users if any
49+
const handleMouseLeave = () => {
50+
if (setCurrentHoveredSlot) {
51+
setCurrentHoveredSlot(null);
52+
}
53+
// If there's a selected slot, show its users
54+
if (selectedTimeslot !== null) {
55+
setCurrentAvailableUsers(availableUsers.get(selectedTimeslot) || []);
56+
setCurrentUnavailableUsers(unavailableUsers.get(selectedTimeslot) || []);
57+
}
58+
};
59+
60+
// Handle click - selects/deselects the time slot
61+
const handleTimeslotClick = (index: number, day: Date, timeIndex: number) => {
3462
if (selectedTimeslot === index) {
63+
// Deselect
3564
setSelectedTimeslot(null);
36-
setCurrentAvailableUsers([]);
37-
setCurrentUnavailableUsers([]);
65+
if (onSlotScheduleClick) {
66+
onSlotScheduleClick(null, 0, 0); // Clear selection in parent
67+
}
3868
} else {
69+
// Select
3970
setSelectedTimeslot(index);
40-
setCurrentAvailableUsers(availableUsers.get(index) || []);
41-
setCurrentUnavailableUsers(unavailableUsers.get(index) || []);
71+
if (onSlotScheduleClick) {
72+
const startHour = 10 + timeIndex;
73+
const endHour = 11 + timeIndex;
74+
onSlotScheduleClick(day, startHour, endHour);
75+
}
4276
}
4377
};
4478

@@ -77,17 +111,30 @@ const AvailabilityScheduleView: React.FC<AvailabilityScheduleViewProps> = ({
77111
<TableContainer
78112
sx={{
79113
overflowX: 'auto',
80-
overflowY: 'auto',
81-
maxWidth: '100%'
114+
overflowY: 'hidden',
115+
height: '100%',
116+
display: 'flex',
117+
flexDirection: 'column'
82118
}}
119+
onMouseLeave={handleMouseLeave}
83120
>
84121
<Table
85122
stickyHeader
123+
size="small"
86124
sx={{
125+
height: '100%',
126+
tableLayout: 'fixed',
87127
'& .MuiTableCell-head': {
88-
bgcolor: 'background.paper'
128+
bgcolor: 'background.paper',
129+
px: 0.5,
130+
py: 0.5
131+
},
132+
'& .MuiTableCell-body': {
133+
px: 0,
134+
py: 0,
135+
height: `calc((100% - 40px) / 12)` // 12 time slots, minus header height
89136
},
90-
minWidth: 650
137+
minWidth: 400
91138
}}
92139
>
93140
<TableHead>
@@ -113,12 +160,12 @@ const AvailabilityScheduleView: React.FC<AvailabilityScheduleViewProps> = ({
113160
{potentialDays.map((day, dayIndex) => {
114161
const index = dayIndex * enumToArray(REVIEW_TIMES).length + timeIndex;
115162
return (
116-
<TableCell sx={{ p: 0 }}>
163+
<TableCell key={index} sx={{ p: 0 }}>
117164
<EventTimeSlot
118-
key={index}
119165
backgroundColor={getBackgroundColor(availableUsers.get(index)?.length, totalUsers)}
120166
selected={selectedTimeslot === index}
121-
onClick={() => handleTimeslotClick(index, day)}
167+
onClick={() => handleTimeslotClick(index, day, timeIndex)}
168+
onMouseEnter={() => handleTimeslotHover(index, day, timeIndex)}
122169
/>
123170
</TableCell>
124171
);

src/frontend/src/pages/CalendarPage/CalendarDayCard.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,7 @@ const CalendarDayCard: React.FC<CalendarDayCardProps> = ({
159159
const baseColor = specificCalendar?.color ?? 'gray';
160160
const isPending =
161161
event.status === EventStatus.UNCONFIRMED ||
162+
event.status === EventStatus.CONFIRMED ||
162163
event.approved === ConflictStatus.PENDING ||
163164
event.approved === ConflictStatus.DENIED;
164165
const bgColor = isPending ? getMutedColor(baseColor, 0.35) : baseColor;

0 commit comments

Comments
 (0)