diff --git a/app/admin/components/CommunicationDialog.tsx b/app/admin/components/CommunicationDialog.tsx index 8bef4652..871849a2 100644 --- a/app/admin/components/CommunicationDialog.tsx +++ b/app/admin/components/CommunicationDialog.tsx @@ -241,8 +241,7 @@ export function CommunicationDialog(props: CommunicationDialogProps) { } - An e-mail will automatically be sent to let them know. In which language - should the message be written? + In which language should the message be written? diff --git a/app/admin/events/[event]/[team]/applications/Applications.tsx b/app/admin/events/[event]/[team]/applications/Applications.tsx index 525f2fda..5785632b 100644 --- a/app/admin/events/[event]/[team]/applications/Applications.tsx +++ b/app/admin/events/[event]/[team]/applications/Applications.tsx @@ -405,7 +405,8 @@ export function Applications(props: ApplicationsProps) { <> You're about to approve {application?.firstName}'s application to - help out during this event. + help out during this event. An e-mail will automatically be + sent to let them know. } apiParams={{ type: 'approve-volunteer', @@ -421,7 +422,8 @@ export function Applications(props: ApplicationsProps) { <> You're about to reject {application?.firstName}'s application to - help out during this event. + help out during this event. An e-mail will automatically be + sent to let them know. } apiParams={{ type: 'reject-volunteer', diff --git a/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx b/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx index 7acb762d..10e2926a 100644 --- a/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx +++ b/app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx @@ -4,6 +4,8 @@ 'use client'; import Link from 'next/link'; +import { useCallback, useState } from 'react'; +import { useRouter } from 'next/navigation'; import { default as MuiLink } from '@mui/material/Link'; import Chip from '@mui/material/Chip'; @@ -15,7 +17,9 @@ import Typography from '@mui/material/Typography'; import WhatsAppIcon from '@mui/icons-material/WhatsApp'; import type { RetentionContext, RetentionRowModel } from '@app/api/admin/retention/[[...id]]/route'; -import { type RemoteDataTableColumn, RemoteDataTable } from '@app/admin/components/RemoteDataTable'; +import { CommunicationDialog } from '@app/admin/components/CommunicationDialog'; +import { RemoteDataTable, type RemoteDataTableColumn } from '@app/admin/components/RemoteDataTable'; +import { callApi } from '@lib/callApi'; /** * Props accepted by the component. @@ -38,6 +42,11 @@ export type RetentionDataTableProps = RetentionContext & { * reaching out to particular volunteers can be claimed by any of the seniors. */ export function RetentionDataTable(props: RetentionDataTableProps) { + const [ emailOpen, setEmailOpen ] = useState(false); + const [ emailTarget, setEmailTarget ] = useState(); + + const router = useRouter(); + const columns: RemoteDataTableColumn[] = [ { field: 'name', @@ -163,13 +172,18 @@ export function RetentionDataTable(props: RetentionDataTableProps) { ); } + const openEmailDialog = () => { + setEmailTarget(params.row); + setEmailOpen(true); + }; + return ( Unassigned - + { props.enableWhatsApp && @@ -201,12 +215,50 @@ export function RetentionDataTable(props: RetentionDataTableProps) { } ]; + const handleEmailClose = useCallback(() => setEmailOpen(false), [ /* no deps */ ]); + const handleEmailSubmit = useCallback(async (subject?: string, message?: string) => { + if (!subject || !message) + return { error: 'Something went wrong, and no message was available to send.' }; + + const result = await callApi('post', '/api/admin/retention', { + event: props.event, + team: props.team, + userId: emailTarget?.id ?? 0, + email: { subject, message }, + }); + + if (!result.success) + return { error: result.error ?? '' }; + + router.refresh(); + + return { success: 'They have been invited to participate!' }; + + }, [ emailTarget, props.event, props.team, router ]); + return ( <> + + + You're about to send an e-mail to + {emailTarget?.name} inviting them to help + out during the upcoming AnimeCon event. + + } apiParams={{ + type: 'remind-participation', + remindParticipation: { + userId: emailTarget?.id ?? 0, + event: props.event, + team: props.team, + }, + }} onSubmit={handleEmailSubmit} /> ); } diff --git a/app/admin/events/[event]/[team]/volunteers/[volunteer]/VolunteerHeader.tsx b/app/admin/events/[event]/[team]/volunteers/[volunteer]/VolunteerHeader.tsx index 765af85f..7143cefc 100644 --- a/app/admin/events/[event]/[team]/volunteers/[volunteer]/VolunteerHeader.tsx +++ b/app/admin/events/[event]/[team]/volunteers/[volunteer]/VolunteerHeader.tsx @@ -256,7 +256,8 @@ function ChangeTeamDialog(props: ChangeTeamDialogProps) { <> You're about to change {volunteer.firstName}'s team to the - {selectedTeam.teamName}. + {selectedTeam.teamName}. An e-mail will + automatically be sent to let them know. } apiParams={{ type: 'change-team', @@ -568,7 +569,8 @@ export function VolunteerHeader(props: VolunteerHeaderProps) { <> You're about to cancel {volunteer.firstName}'s participation in - this event. + this event. An e-mail will automatically be sent to let + them know. } apiParams={{ type: 'cancel-participation', @@ -585,7 +587,8 @@ export function VolunteerHeader(props: VolunteerHeaderProps) { <> You're about to reinstate {volunteer.firstName} to participation in - this event. + this event. An e-mail will automatically be sent to let + them know. } apiParams={{ type: 'reinstate-participation', diff --git a/app/api/admin/retention/[[...id]]/route.ts b/app/api/admin/retention/[[...id]]/route.ts index 7567a2db..97f89f5e 100644 --- a/app/api/admin/retention/[[...id]]/route.ts +++ b/app/api/admin/retention/[[...id]]/route.ts @@ -1,6 +1,7 @@ // Copyright 2023 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 { forbidden, notFound } from 'next/navigation'; import { z } from 'zod'; @@ -8,10 +9,13 @@ import { type DataTableEndpoints, createDataTableApi } from '@app/api/createData import { LogSeverity, LogType, Log } from '@lib/Log'; import { RegistrationStatus } from '@lib/database/Types'; import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; +import { executeAction } from '@app/api/Action'; import { getEventBySlug } from '@lib/EventLoader'; import { readSetting } from '@lib/Settings'; import db, { tEvents, tRetention, tTeams, tUsersEvents, tUsers } from '@lib/database'; +import { remindParticipation, kRemindParticipationDefinition } from '../remindParticipation'; + /** * Row model for an individual piece of advice offered by Del a Rie Advies. */ @@ -327,3 +331,10 @@ export const { GET, PUT } = createDataTableApi(kRetentionRowModel, kRetentionCon }); }, }); + +/** + * POST /api/admin/retention + */ +export async function POST(request: NextRequest): Promise { + return executeAction(request, kRemindParticipationDefinition, remindParticipation); +} diff --git a/app/api/admin/retention/remindParticipation.ts b/app/api/admin/retention/remindParticipation.ts new file mode 100644 index 00000000..6d859418 --- /dev/null +++ b/app/api/admin/retention/remindParticipation.ts @@ -0,0 +1,201 @@ +// Copyright 2025 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 { forbidden, notFound, unauthorized } from 'next/navigation'; +import { z } from 'zod'; + +import type { ActionProps } from '../../Action'; +import type { ApiDefinition, ApiRequest, ApiResponse } from '../../Types'; +import { LogSeverity, LogType, Log } from '@lib/Log'; +import { RegistrationStatus, RetentionStatus } from '@lib/database/Types'; +import { SendEmailTask } from '@lib/scheduler/tasks/SendEmailTask'; +import { Temporal, formatDate } from '@lib/Temporal'; +import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; +import { getEventBySlug } from '@lib/EventLoader'; + +import db, { tEvents, tRetention, tTeams, tUsersEvents, tUsers } from '@lib/database'; + +/** + * Interface definition for an API to remind a volunteer to participate in an event. + */ +export const kRemindParticipationDefinition = z.object({ + request: z.object({ + /** + * Event for which the volunteer should be reminded to participate. + */ + event: z.string(), + + /** + * Team for which the volunteer should be reminded to participate. + */ + team: z.string(), + + /** + * Unique ID of the user to whom the message should be send. + */ + userId: z.number(), + + /** + * The e-mail message that should be send. + */ + email: z.object({ + /** + * Subject of the e-mail message. + */ + subject: z.string(), + + /** + * Body of the e-mail message. + */ + message: z.string(), + + }).optional(), + + /** + * The message that should be send over WhatsApp. + */ + whatsApp: z.object({ + // TODO... + }).optional(), + + }), + response: z.strictObject({ + /** + * Whether the operation could be completed successfully. + */ + success: z.boolean(), + + /** + * Optional error message explaining what went wrong. + */ + error: z.string().optional(), + }), +}); + +export type RemindParticipationDefinition = ApiDefinition; + +type Request = ApiRequest; +type Response = ApiResponse; + +/** + * API that allows an automated message to remind a volunteer to participate in an event. + */ +export async function remindParticipation(request: Request, props: ActionProps): Promise { + executeAccessCheck(props.authenticationContext, { + check: 'admin-event', + event: request.event, + + permission: { + permission: 'event.retention', + scope: { + event: request.event, + team: request.team, + }, + }, + }); + + if (!props.user) + unauthorized(); + + const event = await getEventBySlug(request.event); + if (!event) + notFound(); // invalid event was given + + const teamId = await db.selectFrom(tTeams) + .selectOneColumn(tTeams.teamId) + .where(tTeams.teamSlug.equals(request.team)) + .executeSelectNoneOrOne(); + + if (!teamId) + notFound(); // invalid team was given + + const retentionJoin = tRetention.forUseInLeftJoin(); + const usersEventsJoin = tUsersEvents.forUseInLeftJoinAs('curEvent'); + + const dbInstance = db; + const volunteer = await dbInstance.selectFrom(tUsersEvents) + .innerJoin(tEvents) + .on(tEvents.eventId.equals(tUsersEvents.eventId)) + .innerJoin(tUsers) + .on(tUsers.userId.equals(tUsersEvents.userId)) + .leftJoin(usersEventsJoin) + .on(usersEventsJoin.eventId.equals(event.eventId)) + .and(usersEventsJoin.userId.equals(tUsersEvents.userId)) + .leftJoin(retentionJoin) + .on(retentionJoin.userId.equals(tUsersEvents.userId)) + .and(retentionJoin.eventId.equals(event.eventId)) + .and(retentionJoin.teamId.equals(tUsersEvents.teamId)) + .where(tUsersEvents.userId.equals(request.userId)) + .and(tUsersEvents.teamId.equals(teamId)) + .and(tUsersEvents.registrationStatus.in( + [ RegistrationStatus.Accepted, RegistrationStatus.Cancelled ])) + .and(usersEventsJoin.registrationStatus.isNull()) + .and(retentionJoin.retentionStatus.isNull()) + .select({ + emailAddress: tUsers.username, + phoneNumber: tUsers.phoneNumber, + }) + .groupBy(tUsersEvents.userId, usersEventsJoin.eventId) + .executeSelectNoneOrOne(); + + if (!volunteer) + forbidden(); // the volunteer is not eligible for being reminded + + if (!!request.email && !!request.whatsApp) + return { success: false, error: 'You can only reach out using e-mail or WhatsApp…' }; + + const noteDate = formatDate(Temporal.Now.instant(), 'MMMM Do'); + const noteMedium = !!request.email ? 'Sent an e-mail' + : 'Sent a WhatsApp message'; + + const affectedRows = await db.insertInto(tRetention) + .set({ + userId: request.userId, + eventId: event.eventId, + teamId: teamId, + retentionStatus: RetentionStatus.Contacting, + retentionAssigneeId: props.user.userId, + retentionNotes: `${noteMedium} (${noteDate})`, + }) + .onConflictDoUpdateSet({ + retentionStatus: RetentionStatus.Contacting, + retentionAssigneeId: props.user.userId, + retentionNotes: `${noteMedium} (${noteDate})`, + }) + .executeInsert(); + + if (!affectedRows) + return { success: false, error: 'Unable to assign this volunteer to you…' }; + + if (!!request.email) { + if (!volunteer.emailAddress) + return { success: false, error: 'We don\'t have their e-mail address on file…' }; + + await SendEmailTask.Schedule({ + sender: `${props.user.firstName} ${props.user.lastName} (AnimeCon)`, + message: { + to: volunteer.emailAddress, + subject: request.email.subject, + markdown: request.email.message, + }, + attribution: { + sourceUserId: props.user.userId, + targetUserId: request.userId, + }, + }); + } else if (!!request.whatsApp) { + return { success: false, error: 'Not yet implemented' }; + } + + await Log({ + type: LogType.AdminEventRetentionUpdate, + severity: LogSeverity.Info, + sourceUser: props.user, + targetUser: request.userId, + data: { + event: event.shortName, + } + }); + + return { success: true }; +} diff --git a/app/api/admin/scheduler/[[...id]]/route.ts b/app/api/admin/scheduler/[[...id]]/route.ts index 8ebed4a2..691734fb 100644 --- a/app/api/admin/scheduler/[[...id]]/route.ts +++ b/app/api/admin/scheduler/[[...id]]/route.ts @@ -2,12 +2,12 @@ // 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 '@app/api/Action'; import { z } from 'zod'; import { type DataTableEndpoints, createDataTableApi } from '../../../createDataTableApi'; import { TaskResult } from '@lib/database/Types'; import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; +import { executeAction } from '@app/api/Action'; import { kTaskFormatFn } from '@lib/scheduler/TaskRegistry'; import db, { tTasks } from '@lib/database'; diff --git a/app/lib/callApi.ts b/app/lib/callApi.ts index 4cc1fe18..474d61b8 100644 --- a/app/lib/callApi.ts +++ b/app/lib/callApi.ts @@ -26,6 +26,7 @@ import type { RefundRequestDefinition } from '@app/api/event/refundRequest'; import type { RegisterActivateDefinition } from '@app/api/auth/registerActivate'; import type { RegisterDefinition } from '@app/api/auth/register'; import type { RegisterPasskeyDefinition } from '@app/api/auth/passkeys/registerPasskey'; +import type { RemindParticipationDefinition } from '@app/api/admin/retention/remindParticipation'; import type { ResetAccessCodeDefinition } from '@app/api/admin/resetAccessCode'; import type { ResetPasswordLinkDefinition } from '@app/api/admin/resetPasswordLink'; import type { ScheduleTaskDefinition } from '@app/api/admin/scheduler/scheduleTask'; @@ -158,6 +159,7 @@ export type ApiEndpoints = { '/api/admin/program/locations': ProgramLocationsEndpoints['create'], '/api/admin/reset-access-code': ResetAccessCodeDefinition, '/api/admin/reset-password-link': ResetPasswordLinkDefinition, + '/api/admin/retention': RemindParticipationDefinition, '/api/admin/scheduler': ScheduleTaskDefinition, '/api/admin/service-health': ServiceHealthDefinition, '/api/admin/trainings': TrainingsEndpoints['create'],