Skip to content

Commit

Permalink
feat: Enable three-click retention reminders via e-mail
Browse files Browse the repository at this point in the history
  • Loading branch information
beverloo committed Jan 2, 2025
1 parent c0aa104 commit 08a910b
Show file tree
Hide file tree
Showing 8 changed files with 280 additions and 10 deletions.
3 changes: 1 addition & 2 deletions app/admin/components/CommunicationDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -241,8 +241,7 @@ export function CommunicationDialog(props: CommunicationDialogProps) {
</Typography> }
<Collapse in={state === 'language'}>
<Typography>
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?
</Typography>
<Stack direction="row" justifyContent="space-between" spacing={2}
alignItems="stretch" sx={{ mt: 2 }}>
Expand Down
6 changes: 4 additions & 2 deletions app/admin/events/[event]/[team]/applications/Applications.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -405,7 +405,8 @@ export function Applications(props: ApplicationsProps) {
<>
You're about to approve
<strong> {application?.firstName}</strong>'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',
Expand All @@ -421,7 +422,8 @@ export function Applications(props: ApplicationsProps) {
<>
You're about to reject
<strong> {application?.firstName}</strong>'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',
Expand Down
56 changes: 54 additions & 2 deletions app/admin/events/[event]/[team]/retention/RetentionDataTable.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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 <RetentionDataTable> component.
Expand All @@ -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<boolean>(false);
const [ emailTarget, setEmailTarget ] = useState<RetentionRowModel | undefined>();

const router = useRouter();

const columns: RemoteDataTableColumn<RetentionRowModel>[] = [
{
field: 'name',
Expand Down Expand Up @@ -163,13 +172,18 @@ export function RetentionDataTable(props: RetentionDataTableProps) {
);
}

const openEmailDialog = () => {
setEmailTarget(params.row);
setEmailOpen(true);
};

return (
<Stack direction="row" alignItems="center">
<Typography component="span" variant="body2"
sx={{ color: 'text.disabled', fontStyle: 'italic', mr: 1 }}>
Unassigned
</Typography>
<IconButton size="small">
<IconButton size="small" onClick={openEmailDialog}>
<MailOutlineIcon color="action" fontSize="inherit" />
</IconButton>
{ props.enableWhatsApp &&
Expand Down Expand Up @@ -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 (
<>
<RemoteDataTable columns={columns} endpoint="/api/admin/retention" enableUpdate
context={{ event: props.event, team: props.team }} refreshOnUpdate
defaultSort={{ field: 'id', sort: 'asc' }} pageSize={100}
disableFooter />

<CommunicationDialog title={`Invite ${emailTarget?.name} to volunteer again`}
open={emailOpen} onClose={handleEmailClose}
confirmLabel="Send" allowSilent={false} description={
<>
You're about to send an e-mail to
<strong> {emailTarget?.name}</strong> 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} />
</>
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -256,7 +256,8 @@ function ChangeTeamDialog(props: ChangeTeamDialogProps) {
<>
You're about to change
<strong> {volunteer.firstName}</strong>'s team to the
<strong> {selectedTeam.teamName}</strong>.
<strong> {selectedTeam.teamName}</strong>. An e-mail will
automatically be sent to let them know.
</>
} apiParams={{
type: 'change-team',
Expand Down Expand Up @@ -568,7 +569,8 @@ export function VolunteerHeader(props: VolunteerHeaderProps) {
<>
You're about to cancel
<strong> {volunteer.firstName}</strong>'s participation in
this event.
this event. An e-mail will automatically be sent to let
them know.
</>
} apiParams={{
type: 'cancel-participation',
Expand All @@ -585,7 +587,8 @@ export function VolunteerHeader(props: VolunteerHeaderProps) {
<>
You're about to reinstate
<strong> {volunteer.firstName}</strong> to participation in
this event.
this event. An e-mail will automatically be sent to let
them know.
</>
} apiParams={{
type: 'reinstate-participation',
Expand Down
11 changes: 11 additions & 0 deletions app/api/admin/retention/[[...id]]/route.ts
Original file line number Diff line number Diff line change
@@ -1,17 +1,21 @@
// 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';

import { type DataTableEndpoints, createDataTableApi } from '@app/api/createDataTableApi';
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.
*/
Expand Down Expand Up @@ -327,3 +331,10 @@ export const { GET, PUT } = createDataTableApi(kRetentionRowModel, kRetentionCon
});
},
});

/**
* POST /api/admin/retention
*/
export async function POST(request: NextRequest): Promise<Response> {
return executeAction(request, kRemindParticipationDefinition, remindParticipation);
}
201 changes: 201 additions & 0 deletions app/api/admin/retention/remindParticipation.ts
Original file line number Diff line number Diff line change
@@ -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<typeof kRemindParticipationDefinition>;

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

/**
* API that allows an automated message to remind a volunteer to participate in an event.
*/
export async function remindParticipation(request: Request, props: ActionProps): Promise<Response> {
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 };
}
Loading

0 comments on commit 08a910b

Please sign in to comment.