From b19edd726c3f67161f445008f060ce25d0c88c1e Mon Sep 17 00:00:00 2001 From: Peter Beverloo Date: Tue, 23 Jan 2024 21:11:30 +0000 Subject: [PATCH] Migrate training configuration to be a RemoteDataTable --- .../[slug]/training/TrainingConfiguration.tsx | 107 ++-------- app/admin/events/[slug]/training/page.tsx | 2 +- app/api/admin/training.ts | 126 +----------- app/api/admin/trainings/[[...id]]/route.ts | 186 ++++++++++++++++++ app/lib/callApi.ts | 5 + 5 files changed, 210 insertions(+), 216 deletions(-) create mode 100644 app/api/admin/trainings/[[...id]]/route.ts diff --git a/app/admin/events/[slug]/training/TrainingConfiguration.tsx b/app/admin/events/[slug]/training/TrainingConfiguration.tsx index 62e6c081..637ecfa5 100644 --- a/app/admin/events/[slug]/training/TrainingConfiguration.tsx +++ b/app/admin/events/[slug]/training/TrainingConfiguration.tsx @@ -6,48 +6,17 @@ import { useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import type { GridRenderCellParams, GridValidRowModel } from '@mui/x-data-grid'; import Paper from '@mui/material/Paper'; import Typography from '@mui/material/Typography'; import type { PageInfo } from '@app/admin/events/verifyAccessAndFetchPageInfo'; -import type { TrainingDefinition } from '@app/api/admin/training'; +import type { TrainingsRowModel } from '@app/api/admin/trainings/[[...id]]/route'; import type { UpdatePublicationDefinition } from '@app/api/admin/updatePublication'; -import { type DataTableColumn, OLD_DataTable } from '@app/admin/DataTable'; import { PublishAlert } from '@app/admin/components/PublishAlert'; +import { RemoteDataTable, type RemoteDataTableColumn } from '@app/admin/components/RemoteDataTable'; import { dayjs } from '@lib/DateTime'; import { issueServerAction } from '@lib/issueServerAction'; -/** - * Configuration options available for a training session. Can be amended by this page. - */ -export interface TrainingConfigurationEntry { - /** - * Unique ID of this entry in the training configuration. - */ - id: number; - - /** - * Address at which the training will be taking place. - */ - trainingAddress?: string; - - /** - * Maximum capacity of the training. - */ - trainingCapacity?: number; - - /** - * Date and time at which the training will commence. - */ - trainingStart?: string; - - /** - * Date and time at which the training will conclude. - */ - trainingEnd?: string; -} - /** * Props accepted by the component. */ @@ -56,11 +25,6 @@ export interface TrainingConfigurationProps { * Information about the event for which training sessions are being shown. */ event: PageInfo['event']; - - /** - * The training sessions that can be displayed by this component. - */ - trainings: TrainingConfigurationEntry[]; } /** @@ -70,48 +34,6 @@ export interface TrainingConfigurationProps { export function TrainingConfiguration(props: TrainingConfigurationProps) { const { event } = props; - async function commitAdd(): Promise { - const response = await issueServerAction('/api/admin/training', { - event: event.slug, - create: { /* empty payload */ } - }); - - if (!response.id) - throw new Error('The server was unable to create a new training session.'); - - return { - id: response.id, - trainingCapacity: 10, - trainingAddress: undefined, - trainingStart: event.startTime, - trainingEnd: event.endTime, - }; - } - - async function commitDelete(oldRow: GridValidRowModel) { - await issueServerAction('/api/admin/training', { - event: event.slug, - delete: { - id: oldRow.id, - }, - }); - } - - async function commitEdit(newRow: GridValidRowModel, oldRow: GridValidRowModel) { - const response = await issueServerAction('/api/admin/training', { - event: event.slug, - update: { - id: oldRow.id, - trainingAddress: newRow.trainingAddress, - trainingStart: newRow.trainingStart, - trainingEnd: newRow.trainingEnd, - trainingCapacity: newRow.trainingCapacity, - } - }); - - return response.success ? newRow : oldRow; - } - const router = useRouter(); const onPublish = useCallback(async (domEvent: unknown, publish: boolean) => { @@ -126,7 +48,11 @@ export function TrainingConfiguration(props: TrainingConfigurationProps) { }, [ event, router ]); - const columns: DataTableColumn[] = [ + const context = { + event: event.slug, + }; + + const columns: RemoteDataTableColumn[] = [ { field: 'id', headerName: /* empty= */ '', @@ -134,34 +60,34 @@ export function TrainingConfiguration(props: TrainingConfigurationProps) { width: 50, }, { - field: 'trainingStart', + field: 'start', headerName: 'Date (start time)', editable: true, sortable: true, flex: 2, - renderCell: (params: GridRenderCellParams) => + renderCell: params => dayjs(params.value).tz(event.timezone).format('YYYY-MM-DD [at] H:mm'), }, { - field: 'trainingEnd', + field: 'end', headerName: 'Date (end time)', editable: true, sortable: true, flex: 2, - renderCell: (params: GridRenderCellParams) => + renderCell: params => dayjs(params.value).tz(event.timezone).format('YYYY-MM-DD [at] H:mm'), }, { - field: 'trainingAddress', + field: 'address', headerName: 'Address', editable: true, sortable: true, flex: 3, }, { - field: 'trainingCapacity', + field: 'capacity', headerName: 'Capacity', editable: true, sortable: true, @@ -180,9 +106,10 @@ export function TrainingConfiguration(props: TrainingConfigurationProps) { ? 'Training information has been published to volunteers.' : 'Training information has not yet been published to volunteers.' } - + ); } diff --git a/app/admin/events/[slug]/training/page.tsx b/app/admin/events/[slug]/training/page.tsx index 325b6ffa..fc554cb1 100644 --- a/app/admin/events/[slug]/training/page.tsx +++ b/app/admin/events/[slug]/training/page.tsx @@ -218,7 +218,7 @@ export default async function EventTrainingPage(props: NextRouterParams<'slug'>) - + ); } diff --git a/app/api/admin/training.ts b/app/api/admin/training.ts index 26adc8a2..f936be3e 100644 --- a/app/api/admin/training.ts +++ b/app/api/admin/training.ts @@ -6,10 +6,9 @@ import { z } from 'zod'; import type { ActionProps } from '../Action'; import { Log, LogSeverity, LogType } from '@lib/Log'; import { Privilege } from '@lib/auth/Privileges'; -import { dayjs } from '@lib/DateTime'; import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; import { getEventBySlug } from '@lib/EventLoader'; -import db, { tTrainingsAssignments, tTrainings } from '@lib/database'; +import db, { tTrainingsAssignments } from '@lib/database'; /** * Interface definition for the Training API, exposed through /api/admin/training. @@ -41,52 +40,6 @@ export const kTrainingDefinition = z.object({ confirmed: z.boolean(), }).optional(), - - /** - * Must be set to an empty object when a new training session is being added. - */ - create: z.object({ /* empty object */ }).optional(), - - /** - * Must be set to an object when a training session is being deleted. - */ - delete: z.object({ - /** - * Unique ID of the training session that should be removed. - */ - id: z.number(), - }).optional(), - - /** - * Must be set to an object when a training session is being updated. - */ - update: z.object({ - /** - * Unique ID of the training session that is being updated. - */ - id: z.number(), - - /** - * Address at which the training will be taking place. - */ - trainingAddress: z.string().optional(), - - /** - * Date at which the training will be taking place. - */ - trainingStart: z.string().optional(), - - /** - * Date at which the training will be taking place. - */ - trainingEnd: z.string().optional(), - - /** - * Maximum number of people that can join this training session. - */ - trainingCapacity: z.number().optional(), - - }).optional(), }), response: z.strictObject({ /** @@ -186,82 +139,5 @@ export async function training(request: Request, props: ActionProps): Promise 0) { - await Log({ - type: LogType.AdminEventTrainingMutation, - severity: LogSeverity.Info, - sourceUser: props.user, - data: { - eventName: event.shortName, - mutation: 'Deleted', - }, - }); - } - - return { success: !!affectedRows }; - } - - // Operation: update - if (request.update !== undefined) { - const affectedRows = await db.update(tTrainings) - .set({ - trainingAddress: request.update.trainingAddress, - trainingStart: - request.update.trainingStart ? dayjs.utc(request.update.trainingStart) - : undefined, - trainingEnd: - request.update.trainingEnd ? dayjs.utc(request.update.trainingEnd) - : undefined, - - trainingCapacity: request.update.trainingCapacity, - }) - .where(tTrainings.trainingId.equals(request.update.id)) - .and(tTrainings.eventId.equals(event.eventId)) - .executeUpdate(/* min= */ 0, /* max= */ 1); - - if (affectedRows > 0) { - await Log({ - type: LogType.AdminEventTrainingMutation, - severity: LogSeverity.Info, - sourceUser: props.user, - data: { - eventName: event.shortName, - mutation: 'Updated', - }, - }); - } - - return { success: !!affectedRows }; - } - return { success: false }; } diff --git a/app/api/admin/trainings/[[...id]]/route.ts b/app/api/admin/trainings/[[...id]]/route.ts new file mode 100644 index 00000000..f13708b4 --- /dev/null +++ b/app/api/admin/trainings/[[...id]]/route.ts @@ -0,0 +1,186 @@ +// 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 { notFound } from 'next/navigation'; +import { z } from 'zod'; + +import { type DataTableEndpoints, createDataTableApi } from '@app/api/createDataTableApi'; +import { LogSeverity, LogType, Log } from '@lib/Log'; +import { Privilege } from '@lib/auth/Privileges'; +import { dayjs } from '@lib/DateTime'; +import { executeAccessCheck } from '@lib/auth/AuthenticationContext'; +import { getEventBySlug } from '@lib/EventLoader'; +import db, { tTrainings } from '@lib/database'; + +/** + * Row model for training entry, as can be shown and modified in the administration area. + */ +const kTrainingRowModel = z.object({ +/** + * Unique ID of this entry in the training configuration. + */ + id: z.number(), + + /** + * Address at which the training will be taking place. + */ + address: z.string().optional(), + + /** + * Maximum capacity of the training. + */ + capacity: z.number(), + + /** + * Date and time at which the training will commence, shared in UTC. + */ + start: z.string(), + + /** + * Date and time at which the training will conclude, shared in UTC. + */ + end: z.string(), +}); + +/** + * Context required for the Training API. + */ +const kTrainingContext = z.object({ + context: z.object({ + /** + * Unique slug of the training is in scope of. + */ + event: z.string(), + }), +}); + +/** + * Enable use of the Training API in `callApi()`. + */ +export type TrainingsEndpoints = + DataTableEndpoints; + +/** + * Row model expected by the Training API. + */ +export type TrainingsRowModel = z.infer; + +/** + * Implementation of the Training API. + * + * The following endpoints are provided by this implementation: + * + * GET /api/admin/trainings + * POST /api/admin/trainings + * DELETE /api/admin/trainings/:id + * PUT /api/admin/trainings/:id + */ +export const { DELETE, POST, PUT, GET } = createDataTableApi(kTrainingRowModel, kTrainingContext, { + async accessCheck({ context }, action, props) { + executeAccessCheck(props.authenticationContext, { + check: 'admin-event', + event: context.event, + privilege: Privilege.EventTrainingManagement, + }); + }, + + async create({ context }) { + const event = await getEventBySlug(context.event); + if (!event) + notFound(); + + const insertId = + await db.insertInto(tTrainings) + .set({ + eventId: event.eventId, + trainingStart: dayjs(event.startTime), + trainingEnd: dayjs(event.endTime), + }) + .returningLastInsertedId() + .executeInsert(); + + return { + success: true, + row: { + id: insertId, + capacity: 15, + start: event.startTime, + end: event.endTime, + }, + }; + }, + + async delete({ context, id }) { + const event = await getEventBySlug(context.event); + if (!event) + notFound(); + + const affectedRows = + await db.deleteFrom(tTrainings) + .where(tTrainings.trainingId.equals(id)) + .and(tTrainings.eventId.equals(event.eventId)) + .executeDelete(/* min= */ 0, /* max= */ 1); + + return { success: !!affectedRows }; + }, + + async list({ context, pagination, sort }) { + const event = await getEventBySlug(context.event); + if (!event) + notFound(); + + const dbInstance = db; + const trainings = await dbInstance.selectFrom(tTrainings) + .where(tTrainings.eventId.equals(event.id)) + .and(tTrainings.trainingVisible.equals(/* true= */ 1)) + .select({ + id: tTrainings.trainingId, + address: tTrainings.trainingAddress, + capacity: tTrainings.trainingCapacity, + start: dbInstance.asDateTimeString(tTrainings.trainingStart, 'required'), + end: dbInstance.asDateTimeString(tTrainings.trainingEnd, 'required'), + }) + .orderBy(sort?.field ?? 'start', sort?.sort ?? 'asc') + .limitIfValue(pagination ? pagination.pageSize : null) + .offsetIfValue(pagination ? pagination.page * pagination.pageSize : null) + .executeSelectPage(); + + return { + success: true, + rowCount: trainings.count, + rows: trainings.data, + }; + }, + + async update({ context, id, row }) { + const event = await getEventBySlug(context.event); + if (!event) + notFound(); + + const affectedRows = await db.update(tTrainings) + .set({ + trainingAddress: row.address, + trainingCapacity: row.capacity, + trainingStart: dayjs.utc(row.start), + trainingEnd: dayjs.utc(row.end) + }) + .where(tTrainings.trainingId.equals(id)) + .and(tTrainings.eventId.equals(event.eventId)) + .executeUpdate(/* min= */ 0, /* max= */ 1); + + return { success: !!affectedRows }; + }, + + async writeLog({ context }, mutation, props) { + const event = await getEventBySlug(context.event); + await Log({ + type: LogType.AdminEventTrainingMutation, + severity: LogSeverity.Info, + sourceUser: props.user, + data: { + eventName: event!.shortName, + mutation: mutation, + }, + }); + }, +}); diff --git a/app/lib/callApi.ts b/app/lib/callApi.ts index 89a28356..787de5ed 100644 --- a/app/lib/callApi.ts +++ b/app/lib/callApi.ts @@ -35,6 +35,7 @@ import type { ProgramChangesEndpoints } from '@app/api/admin/program-changes/rou import type { RefundRequestEndpoints } from '@app/api/admin/refunds/[[...id]]/route'; import type { RetentionEndpoints } from '@app/api/admin/retention/[[...id]]/route'; import type { SchedulerEndpoints } from '@app/api/admin/scheduler/[[...id]]/route'; +import type { TrainingsEndpoints } from '@app/api/admin/trainings/[[...id]]/route'; import type { VendorEndpoints } from '@app/api/admin/vendors/[[...id]]/route'; /** @@ -59,6 +60,7 @@ export type ApiEndpoints = { '/api/admin/retention': RetentionEndpoints['list'], '/api/admin/scheduler': SchedulerEndpoints['list'], '/api/admin/scheduler/:id': SchedulerEndpoints['get'], + '/api/admin/trainings': TrainingsEndpoints['list'], '/api/admin/vendors': VendorEndpoints['list'], '/api/auth/passkeys/list': ListPasskeysDefinition, '/api/nardo': NardoEndpoints['list'], @@ -70,6 +72,7 @@ export type ApiEndpoints = { '/api/admin/hotel-bookings/:slug': CreateBookingDefinition, '/api/admin/scheduler': ScheduleTaskDefinition, '/api/admin/training': TrainingDefinition, + '/api/admin/trainings': TrainingsEndpoints['create'], '/api/admin/vendors': VendorEndpoints['create'], '/api/admin/volunteer-teams': VolunteerTeamsDefinition, '/api/ai/generate/:type': GeneratePromptDefinition, @@ -94,6 +97,7 @@ export type ApiEndpoints = { '/api/admin/content/:id': ContentEndpoints['delete'], '/api/admin/exports/:id': ExportsEndpoints['delete'], '/api/admin/hotel-bookings/:slug/:id': DeleteBookingDefinition, + '/api/admin/trainings/:id': TrainingsEndpoints['delete'], '/api/admin/vendors/:id': VendorEndpoints['delete'], '/api/auth/passkeys/delete': DeletePasskeyDefinition, '/api/nardo/:id': NardoEndpoints['delete'], @@ -103,6 +107,7 @@ export type ApiEndpoints = { '/api/admin/hotel-bookings/:slug/:id': UpdateBookingDefinition, '/api/admin/refunds/:id': RefundRequestEndpoints['update'], '/api/admin/retention/:id': RetentionEndpoints['update'], + '/api/admin/trainings/:id': TrainingsEndpoints['update'], '/api/admin/vendors/:id': VendorEndpoints['update'], '/api/ai/settings': UpdateSettingsDefinition, '/api/application/:event/:team/:userId': UpdateApplicationDefinition,