Skip to content

Commit

Permalink
Enable notes to be managed in the schedule app
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed May 5, 2024
1 parent 362deb2 commit 710b12a
Show file tree
Hide file tree
Showing 8 changed files with 261 additions and 3 deletions.
1 change: 1 addition & 0 deletions app/api/application/updateApplication.ts
Original file line number Diff line number Diff line change
Expand Up @@ -218,6 +218,7 @@ export async function updateApplication(request: Request, props: ActionProps): P
targetUser: request.userId,
data: {
event: requestContext.event,
notes: request.notes,
},
});
}
Expand Down
5 changes: 5 additions & 0 deletions app/api/event/schedule/PublicSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,11 @@ export const kPublicSchedule = z.strictObject({
*/
enableKnowledgeBaseSearch: z.boolean(),

/**
* Whether the volunteer is able to edit notes of other volunteers.
*/
enableNotesEditor: z.boolean(),

/**
* Amount of fuzziness to apply to the search results. While this allows minor compensation
* for typos, a high value could lead to less relevant results being presented to the user.
Expand Down
2 changes: 2 additions & 0 deletions app/api/event/schedule/getSchedule.ts
Original file line number Diff line number Diff line change
Expand Up @@ -389,6 +389,7 @@ export async function getSchedule(request: Request, props: ActionProps): Promise
if (!event || !event.festivalId)
notFound();

// TODO: Should `isLeader` also contain senior volunteers w/o the event administrator bit?
let isLeader: boolean = can(props.user, Privilege.EventAdministrator);
let team: string | undefined;

Expand Down Expand Up @@ -425,6 +426,7 @@ export async function getSchedule(request: Request, props: ActionProps): Promise
enableHelpRequests: can(props.user, Privilege.EventHelpRequests),
enableKnowledgeBase: settings['schedule-knowledge-base'] ?? false,
enableKnowledgeBaseSearch: settings['schedule-knowledge-base-search'] ?? false,
enableNotesEditor: isLeader,
searchResultFuzziness: settings['schedule-search-candidate-fuzziness'] ?? 0.04,
searchResultLimit: settings['schedule-search-result-limit'] ?? 5,
searchResultMinimumScore: settings['schedule-search-candidate-minimum-score'] ?? 0.37,
Expand Down
14 changes: 14 additions & 0 deletions app/api/event/schedule/notes/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { NextRequest } from 'next/server';
import { executeAction } from '../../../Action';

import { updateNotes, kUpdateNotesDefinition } from '../updateNotes';

/**
* The /api/event/schedule/notes endpoint can be used to update the notes of a particular volunteer.
*/
export async function PUT(request: NextRequest): Promise<Response> {
return executeAction(request, kUpdateNotesDefinition, updateNotes);
}
86 changes: 86 additions & 0 deletions app/api/event/schedule/updateNotes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

import { notFound } from 'next/navigation';
import { z } from 'zod';

import type { ActionProps } from '../../Action';
import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types';
import { LogSeverity, RegistrationStatus } from '@lib/database/Types';
import { Log, LogType } from '@lib/Log';
import { getEventBySlug } from '@lib/EventLoader';
import db, { tUsersEvents } from '@lib/database';

/**
* Interface definition for the Schedule API, exposed through /api/event/schedule/notes
*/
export const kUpdateNotesDefinition = z.object({
request: z.object({
/**
* Unique slug of the event with which the notes should be associated.
*/
event: z.string(),

/**
* Unique ID of the user whose notes should be updated.
*/
userId: z.number(),

/**
* The notes as they should be stored in the database. May be empty.
*/
notes: z.string(),
}),
response: z.strictObject({
/**
* Whether the notes was updated successfully.
*/
success: z.boolean(),

/**
* Error message when something went wrong. Will be presented to the user.
*/
error: z.string().optional(),
}),
});

export type UpdateNotesDefinition = ApiDefinition<typeof kUpdateNotesDefinition>;

type Request = ApiRequest<typeof kUpdateNotesDefinition>;
type Response = ApiResponse<typeof kUpdateNotesDefinition>;

/**
* API through which the notes associated with a volunteer can be updated.
*/
export async function updateNotes(request: Request, props: ActionProps): Promise<Response> {
if (!props.user || !props.authenticationContext.user)
notFound();

const event = await getEventBySlug(request.event);
if (!event)
notFound();

const affectedRows = await db.update(tUsersEvents)
.set({
registrationNotes: !!request.notes.length ? request.notes : undefined
})
.where(tUsersEvents.eventId.equals(event.id))
.and(tUsersEvents.userId.equals(request.userId))
.and(tUsersEvents.registrationStatus.equals(RegistrationStatus.Accepted))
.executeUpdate();

if (!!affectedRows) {
await Log({
type: LogType.EventVolunteerNotes,
severity: LogSeverity.Info,
sourceUser: props.user,
targetUser: request.userId,
data: {
event: event.shortName,
notes: request.notes,
},
});
}

return { success: !!affectedRows };
}
2 changes: 2 additions & 0 deletions app/lib/callApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ import type { UpdateAvatarDefinition } from '@app/api/auth/updateAvatar';
import type { UpdateEventDefinition } from '@app/api/admin/updateEvent';
import type { UpdateHelpRequestDefinition } from '@app/api/event/schedule/updateHelpRequest';
import type { UpdateIntegrationDefinition } from '@app/api/admin/updateIntegration';
import type { UpdateNotesDefinition } from '@app/api/event/schedule/updateNotes';
import type { UpdatePermissionsDefinition } from '@app/api/admin/updatePermissions';
import type { UpdatePublicationDefinition } from '@app/api/admin/updatePublication';
import type { UpdateScheduleEntryDefinition } from '@app/api/admin/event/schedule/updateScheduleEntry';
Expand Down Expand Up @@ -252,6 +253,7 @@ export type ApiEndpoints = {
'/api/ai/settings': UpdateAiSettingsDefinition,
'/api/application/:event/:team/:userId': UpdateApplicationDefinition,
'/api/event/schedule/help-request': UpdateHelpRequestDefinition,
'/api/event/schedule/notes': UpdateNotesDefinition,
'/api/nardo/:id': NardoEndpoints['update'],
},
};
Expand Down
111 changes: 111 additions & 0 deletions app/schedule/[event]/components/NotesEditorDialog.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// Copyright 2024 Peter Beverloo & AnimeCon. All rights reserved.
// Use of this source code is governed by a MIT license that can be found in the LICENSE file.

'use client';

import { useCallback, useEffect, useState, type ChangeEvent } from 'react';

import Alert from '@mui/material/Alert';
import Button from '@mui/material/Button';
import Collapse from '@mui/material/Collapse';
import Dialog from '@mui/material/Dialog';
import DialogActions from '@mui/material/DialogActions';
import DialogContent from '@mui/material/DialogContent';
import DialogTitle from '@mui/material/DialogTitle';
import LoadingButton from '@mui/lab/LoadingButton';
import TextField from '@mui/material/TextField';

/**
* Props accepted by the <NotesEditorDialog> component.
*/
export interface NotesEditorDialogProps {
/**
* To be called when the notes dialog has been closed.
*/
onClose?: () => void;

/**
* To be called when the updates notes are to be submitted. When the return value is truthy,
* the dialog will be closed, whereas an error will be shown in case of a failure.
*/
onSubmit?: (notes: string) => Promise<boolean>;

/**
* The notes that are stored for the context at the moment.
*/
notes?: string;

/**
* Whether the dialog should be presented to the user.
*/
open?: boolean;
}

/**
* The <NotesEditorDialog> component displays a dialog, when opened, in which the notes for a given
* event or volunteer can be changed. Markdown is supported in updated notes.
*/
export default function NotesEditorDialog(props: NotesEditorDialogProps) {
const { onClose, onSubmit, open } = props;

const [ error, setError ] = useState<boolean>(false);
const [ loading, setLoading ] = useState<boolean>(false);

const [ notes, setNotes ] = useState<string>(props.notes || '');

useEffect(() => setNotes(props.notes || ''), [ props.notes ]);

const handleClose = useCallback(() => {
setTimeout(() => {
setError(false);
setNotes(props.notes || '');
}, 350);

if (!!onClose)
onClose();

}, [ onClose, props.notes, setNotes ]);

const handleUpdateNotes = useCallback((event: ChangeEvent<HTMLTextAreaElement>) => {
setNotes(event.target.value);

}, [ /* no deps */ ]);

const handleSubmit = useCallback(async () => {
setError(false);
setLoading(true);
try {
if (!!await onSubmit?.(notes))
handleClose();
else
setError(true);
} catch (error: any) {
setError(true);
} finally {
setLoading(false);
}
}, [ handleClose, notes, onSubmit ]);

return (
<Dialog onClose={handleClose} open={!!open} fullWidth>
<DialogTitle sx={{ mb: -1 }}>
What should we keep in mind?
</DialogTitle>
<DialogContent sx={{ pt: '8px !important' }}>
<TextField fullWidth multiline label="Notes" size="small"
value={notes} onChange={handleUpdateNotes} />
<Collapse in={!!error}>
<Alert severity="error" sx={{ mt: 2 }}>
The notes could not be saved. Try again later?
</Alert>
</Collapse>
</DialogContent>
<DialogActions sx={{ pr: 3, pb: 2, mt: -1 }}>
<Button color="inherit" onClick={handleClose}>Cancel</Button>
<LoadingButton variant="contained" onClick={handleSubmit} loading={!!loading}>
Save
</LoadingButton>
</DialogActions>
</Dialog>
);
}
43 changes: 40 additions & 3 deletions app/schedule/[event]/volunteers/[volunteer]/VolunteerPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,15 @@
'use client';

import Link from 'next/link';
import { useCallback, useContext } from 'react';
import dynamic from 'next/dynamic';
import { useCallback, useContext, useState } from 'react';
import { useRouter } from 'next/navigation';

import AlertTitle from '@mui/material/AlertTitle';
import Box from '@mui/material/Box';
import Card from '@mui/material/Card';
import CardHeader from '@mui/material/CardHeader';
import EditNoteIcon from '@mui/icons-material/EditNote';
import IconButton from '@mui/material/IconButton';
import NotesIcon from '@mui/icons-material/Notes';
import PhoneIcon from '@mui/icons-material/Phone';
Expand All @@ -25,6 +27,10 @@ import { ScheduleContext } from '../../ScheduleContext';
import { SetTitle } from '../../components/SetTitle';
import { callApi } from '@lib/callApi';

const NotesEditorDialog = dynamic(() => import('../../components/NotesEditorDialog'), {
ssr: false,
});

/**
* Props accepted by the <VolunteerPageProps> component.
*/
Expand Down Expand Up @@ -87,7 +93,30 @@ export function VolunteerPage(props: VolunteerPageProps) {
// ---------------------------------------------------------------------------------------------
// Notes management:
// ---------------------------------------------------------------------------------------------
// TODO

const [ noteEditorOpen, setNoteEditorOpen ] = useState<boolean>(false);

const handleCloseNotes = useCallback(() => setNoteEditorOpen(false), [ /* no deps */ ]);
const handleOpenNotes = useCallback(() => setNoteEditorOpen(true), [ /* no deps */ ]);

const handleSubmitNotes = useCallback(async (notes: string) => {
if (!schedule)
return false; // unable to update notes when the event is not known

const response = await callApi('put', '/api/event/schedule/notes', {
event: schedule.slug,
userId: parseInt(props.userId, /* radix= */ 10),
notes,
});

if (!!response.success) {
refresh?.();
router.refresh();
}

return response.success;

}, [ props.userId, refresh, router, schedule ]);

// ---------------------------------------------------------------------------------------------

Expand Down Expand Up @@ -120,6 +149,12 @@ export function VolunteerPage(props: VolunteerPageProps) {
sx={{ '& .MuiCardHeader-action': { alignSelf: 'center' } }}
action={
<Stack direction="row" spacing={1} sx={{ pr: 1 }}>
{ !!schedule.config.enableNotesEditor &&
<Tooltip title="Edit their notes">
<IconButton onClick={handleOpenNotes}>
<EditNoteIcon color="primary" />
</IconButton>
</Tooltip> }
{ !!phoneNumber &&
<Tooltip title="Give them a call">
<IconButton LinkComponent={Link} href={phoneNumber}>
Expand Down Expand Up @@ -152,7 +187,9 @@ export function VolunteerPage(props: VolunteerPageProps) {
</Stack>
</Card> }
{ /* TODO: Schedule */ }
{ /* TODO: Notes editor */ }
{ !!schedule.config.enableNotesEditor &&
<NotesEditorDialog onClose={handleCloseNotes} onSubmit={handleSubmitNotes}
notes={volunteer.notes} open={noteEditorOpen} /> }
</>
);
}

0 comments on commit 710b12a

Please sign in to comment.