From 5990d1397e6e6bfd09c983958faa2f51b46ecd77 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 6 Jul 2024 13:12:11 +0100 Subject: [PATCH 01/38] Update equipment training quiz event - as not deployed yet --- src/queries/equipment/view-model.ts | 18 +++++++++++++++--- src/types/domain-event.ts | 3 ++- 2 files changed, 17 insertions(+), 4 deletions(-) diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index 72cff233..fb19aec9 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -1,13 +1,24 @@ import {DateTime} from 'luxon'; import {User} from '../../types'; +export type QuizID = string; + export type QuizResultViewModel = { - email: string; + id: QuizID; score: number; maxScore: number; percentage: number; passed: boolean; timestamp: DateTime; + previousAttempts: QuizID[]; + + emailProvided: string; + memberNumberProvided: number; + + memberNumberFound: boolean; + emailMemberNumber: number | null; + + memberDetailsMatch: boolean; }; export type ViewModel = { @@ -21,7 +32,8 @@ export type ViewModel = { trainedMembers: ReadonlyArray; }; trainingQuizResults: { - passed: ReadonlyArray; - all: ReadonlyArray; + // Each member should only appear in one of these to avoid confusion. + quiz_passed_not_trained: ReadonlyArray; + failed_quiz_not_passed: ReadonlyArray; }; }; diff --git a/src/types/domain-event.ts b/src/types/domain-event.ts index e9e32331..aecc3612 100644 --- a/src/types/domain-event.ts +++ b/src/types/domain-event.ts @@ -63,7 +63,8 @@ export const DomainEvent = t.union([ eventCodec('EquipmentTrainingQuizResult', { equipmentId: tt.UUID, trainingSheetId: t.string, - email: t.string, + memberNumberProvided: t.number, + emailProvided: t.string, score: t.number, id: tt.UUID, maxScore: t.number, From 9f046dfe78ac74de0da0de0b62ea9770c4363c97 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 6 Jul 2024 13:44:08 +0100 Subject: [PATCH 02/38] Handle more sheet types/formats --- src/training-sheets/events.ts | 4 +- src/training-sheets/google.ts | 188 +++++++++++++++++++++------------- src/types/domain-event.ts | 2 +- 3 files changed, 116 insertions(+), 78 deletions(-) diff --git a/src/training-sheets/events.ts b/src/training-sheets/events.ts index a7ffa8bb..b47ce4c4 100644 --- a/src/training-sheets/events.ts +++ b/src/training-sheets/events.ts @@ -6,9 +6,7 @@ export type RegEvent = EventOfType<'EquipmentTrainingSheetRegistered'>; export const QzEventDuplicate: Eq = { equals: (a: QzEvent, b: QzEvent) => - a.email === b.email && + a.quizAnswers === b.quizAnswers && a.timestampEpochS === b.timestampEpochS && - a.score === b.score && - a.equipmentId === b.equipmentId && a.trainingSheetId === b.trainingSheetId, }; diff --git a/src/training-sheets/google.ts b/src/training-sheets/google.ts index 9965124c..09ebf5b3 100644 --- a/src/training-sheets/google.ts +++ b/src/training-sheets/google.ts @@ -10,6 +10,10 @@ import {UUID} from 'io-ts-types'; import {DateTime} from 'luxon'; import {QzEvent} from './events'; +// Bounds to prevent clearly broken parsing. +const MIN_RECOGNISED_MEMBER_NUMBER = 0; +const MAX_RECOGNISED_MEMBER_NUMBER = 1_000_000; + const extractRowFormattedValues = ( row: sheets_v4.Schema$RowData ): O.Option => { @@ -68,6 +72,27 @@ const extractEmail = ( return O.some(rowValue.trim()); }; +const extractMemberNumber = ( + rowValue: string | number | undefined | null +): O.Option => { + if (!rowValue) { + return O.none; + } + if (typeof rowValue === 'string') { + rowValue = parseInt(rowValue.trim(), 10); + } + + if ( + isNaN(rowValue) || + rowValue <= MIN_RECOGNISED_MEMBER_NUMBER || + rowValue > MAX_RECOGNISED_MEMBER_NUMBER + ) { + return O.none; + } + + return O.some(rowValue); +}; + const extractTimestamp = ( rowValue: string | undefined | null ): O.Option => { @@ -83,6 +108,92 @@ const extractTimestamp = ( } }; +const EMAIL_COLUMN_NAMES = ['email address', 'email']; + +const extractQuizSheetInformation = ( + logger: Logger, + sheetData: sheets_v4.Schema$GridData +) => { + const columnNames = extractRowFormattedValues(sheetData.rowData[0]); + if (O.isNone(columnNames)) { + logger.debug('Failed to find column names'); + return O.none; + } + logger.trace('Found column names for sheet %o', columnNames.value); + + return { + timestamp: columnNames.value.findIndex( + val => val.toLowerCase() === 'timestamp' + ), + email: columnNames.value.findIndex(val => + EMAIL_COLUMN_NAMES.includes(val.toLowerCase()) + ), + score: columnNames.value.findIndex(val => val.toLowerCase() === 'score'), + memberNumber: columnNames.value.findIndex( + val => val.toLowerCase() === 'membership number' + ), + }; +}; + +const extractFromRow = + (logger: Logger, sheetInfo: ReturnType) => + (row: sheets_v4.Schema$RowData): O.Option => { + if (!row.values) { + return O.none; + } + + const email = + columnIndexes.email >= 0 + ? extractEmail(row.values[columnIndexes.email].formattedValue) + : O.none; + const memberNumber = extractMemberNumber( + row.values[columnIndexes.memberNumber].formattedValue + ); + const score = extractScore(row.values[columnIndexes.score].formattedValue); + const timestampEpochS = extractTimestamp( + row.values[columnIndexes.timestamp].formattedValue + ); + + if (O.isNone(email) && O.isNone(memberNumber)) { + // Note that some quizes only require the member number. + logger.warn( + 'Failed to extract email or member number from row, skipping quiz result' + ); + logger.trace('Skipped quiz row: %O', row.values); + return O.none; + } + if (O.isNone(score)) { + logger.warn('Failed to extract score from row, skipped row'); + logger.trace('Skipped quiz row: %o', row.values); + return O.none; + } + if (O.isNone(timestampEpochS)) { + logger.warn('Failed to extract timestamp from row, skipped row'); + logger.trace('Skipped quiz row: %o', row.values); + return O.none; + } + + const quizAnswers = RA.zip(columnNames.value, row.values).reduce( + (accum, [columnName, columnValue]) => { + accum[columnName] = columnValue.formattedValue ?? null; + return accum; + }, + {} as Record + ); + + return O.some( + constructEvent('EquipmentTrainingQuizResult')({ + id: v4() as UUID, + equipmentId, + emailProvided: email.value, + memberNumberProvided: trainingSheetId, + timestampEpochS: timestampEpochS.value, + ...score.value, + quizAnswers: quizAnswers, + }) + ); + }; + export const extractGoogleSheetData = (logger: Logger, equipmentId: UUID, trainingSheetId: string) => ( @@ -103,84 +214,13 @@ export const extractGoogleSheetData = if (!sheetData.rowData || sheetData.rowData.length < 1) { return O.none; } - const columnNames = extractRowFormattedValues(sheetData.rowData[0]); - if (O.isNone(columnNames)) { - logger.debug('Failed to find column names'); - return O.none; - } - logger.trace('Found column names for sheet %o', columnNames.value); - - const columnIndexes = { - timestamp: columnNames.value.findIndex( - val => val.toLowerCase() === 'timestamp' - ), - email: columnNames.value.findIndex(val => val.toLowerCase() === 'email'), - score: columnNames.value.findIndex(val => val.toLowerCase() === 'score'), - }; return O.some( pipe( sheetData.rowData.slice(1), - RA.map>(row => { - if (!row.values) { - return O.none; - } - - const email = extractEmail( - row.values[columnIndexes.email].formattedValue - ); - const score = extractScore( - row.values[columnIndexes.score].formattedValue - ); - const timestampEpochS = extractTimestamp( - row.values[columnIndexes.timestamp].formattedValue - ); - - if (O.isNone(email)) { - logger.warn( - `Failed to extract email from '${ - row.values[columnIndexes.email].formattedValue - }', skipped row` - ); - return O.none; - } - if (O.isNone(score)) { - logger.warn( - `Failed to extract score from '${ - row.values[columnIndexes.score].formattedValue - }', skipped row` - ); - return O.none; - } - if (O.isNone(timestampEpochS)) { - logger.warn( - `Failed to extract timestamp from '${ - row.values[columnIndexes.score].formattedValue - }', skipped row` - ); - return O.none; - } - - const quizAnswers = RA.zip(columnNames.value, row.values).reduce( - (accum, [columnName, columnValue]) => { - accum[columnName] = columnValue.formattedValue ?? null; - return accum; - }, - {} as Record - ); - - return O.some( - constructEvent('EquipmentTrainingQuizResult')({ - id: v4() as UUID, - equipmentId, - email: email.value, - trainingSheetId, - timestampEpochS: timestampEpochS.value, - ...score.value, - quizAnswers: quizAnswers, - }) - ); - }), + RA.map( + extractFromRow(logger, extractQuizSheetInformation(logger, sheetData)) + ), RA.filterMap(e => e) ) ); diff --git a/src/types/domain-event.ts b/src/types/domain-event.ts index aecc3612..9e606b72 100644 --- a/src/types/domain-event.ts +++ b/src/types/domain-event.ts @@ -64,7 +64,7 @@ export const DomainEvent = t.union([ equipmentId: tt.UUID, trainingSheetId: t.string, memberNumberProvided: t.number, - emailProvided: t.string, + emailProvided: t.union([t.string, t.null]), score: t.number, id: tt.UUID, maxScore: t.number, From 361af423202d43b3b6a9e9f1f2df279c915233d0 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 6 Jul 2024 17:54:26 +0100 Subject: [PATCH 03/38] Member number / email are optional --- src/training-sheets/google.ts | 83 ++++++++++++++++++++++++----------- src/types/domain-event.ts | 2 +- 2 files changed, 59 insertions(+), 26 deletions(-) diff --git a/src/training-sheets/google.ts b/src/training-sheets/google.ts index 09ebf5b3..7200c351 100644 --- a/src/training-sheets/google.ts +++ b/src/training-sheets/google.ts @@ -110,48 +110,68 @@ const extractTimestamp = ( const EMAIL_COLUMN_NAMES = ['email address', 'email']; +type SheetInfo = { + columnIndexes: { + timestamp: number; + email: number; + score: number; + memberNumber: number; + }; + columnNames: string[]; +}; + const extractQuizSheetInformation = ( logger: Logger, - sheetData: sheets_v4.Schema$GridData -) => { - const columnNames = extractRowFormattedValues(sheetData.rowData[0]); + firstRow: sheets_v4.Schema$RowData +): O.Option => { + const columnNames = extractRowFormattedValues(firstRow); if (O.isNone(columnNames)) { logger.debug('Failed to find column names'); return O.none; } logger.trace('Found column names for sheet %o', columnNames.value); - return { - timestamp: columnNames.value.findIndex( - val => val.toLowerCase() === 'timestamp' - ), - email: columnNames.value.findIndex(val => - EMAIL_COLUMN_NAMES.includes(val.toLowerCase()) - ), - score: columnNames.value.findIndex(val => val.toLowerCase() === 'score'), - memberNumber: columnNames.value.findIndex( - val => val.toLowerCase() === 'membership number' - ), - }; + return O.some({ + columnIndexes: { + timestamp: columnNames.value.findIndex( + val => val.toLowerCase() === 'timestamp' + ), + email: columnNames.value.findIndex(val => + EMAIL_COLUMN_NAMES.includes(val.toLowerCase()) + ), + score: columnNames.value.findIndex(val => val.toLowerCase() === 'score'), + memberNumber: columnNames.value.findIndex( + val => val.toLowerCase() === 'membership number' + ), + }, + columnNames: columnNames.value, + }); }; const extractFromRow = - (logger: Logger, sheetInfo: ReturnType) => + ( + logger: Logger, + sheetInfo: SheetInfo, + equipmentId: UUID, + trainingSheetId: string + ) => (row: sheets_v4.Schema$RowData): O.Option => { if (!row.values) { return O.none; } const email = - columnIndexes.email >= 0 - ? extractEmail(row.values[columnIndexes.email].formattedValue) + sheetInfo.columnIndexes.email >= 0 + ? extractEmail(row.values[sheetInfo.columnIndexes.email].formattedValue) : O.none; const memberNumber = extractMemberNumber( - row.values[columnIndexes.memberNumber].formattedValue + row.values[sheetInfo.columnIndexes.memberNumber].formattedValue + ); + const score = extractScore( + row.values[sheetInfo.columnIndexes.score].formattedValue ); - const score = extractScore(row.values[columnIndexes.score].formattedValue); const timestampEpochS = extractTimestamp( - row.values[columnIndexes.timestamp].formattedValue + row.values[sheetInfo.columnIndexes.timestamp].formattedValue ); if (O.isNone(email) && O.isNone(memberNumber)) { @@ -173,7 +193,7 @@ const extractFromRow = return O.none; } - const quizAnswers = RA.zip(columnNames.value, row.values).reduce( + const quizAnswers = RA.zip(sheetInfo.columnNames, row.values).reduce( (accum, [columnName, columnValue]) => { accum[columnName] = columnValue.formattedValue ?? null; return accum; @@ -185,8 +205,11 @@ const extractFromRow = constructEvent('EquipmentTrainingQuizResult')({ id: v4() as UUID, equipmentId, - emailProvided: email.value, - memberNumberProvided: trainingSheetId, + memberNumberProvided: O.isSome(memberNumber) + ? memberNumber.value + : null, + emailProvided: O.isSome(email) ? email.value : null, + trainingSheetId, timestampEpochS: timestampEpochS.value, ...score.value, quizAnswers: quizAnswers, @@ -210,16 +233,26 @@ export const extractGoogleSheetData = if (!sheet.data || sheet.data.length < 1) { return O.none; } + const sheetData = sheet.data[0]; if (!sheetData.rowData || sheetData.rowData.length < 1) { return O.none; } + const sheetInfo = extractQuizSheetInformation(logger, sheetData.rowData[0]); + + if (O.isNone(sheetInfo)) { + logger.warn( + `Failed to extract sheet info '${trainingSheetId}' for equipment '${equipmentId}'` + ); + return O.none; + } + return O.some( pipe( sheetData.rowData.slice(1), RA.map( - extractFromRow(logger, extractQuizSheetInformation(logger, sheetData)) + extractFromRow(logger, sheetInfo.value, equipmentId, trainingSheetId) ), RA.filterMap(e => e) ) diff --git a/src/types/domain-event.ts b/src/types/domain-event.ts index 9e606b72..303b8b76 100644 --- a/src/types/domain-event.ts +++ b/src/types/domain-event.ts @@ -63,7 +63,7 @@ export const DomainEvent = t.union([ eventCodec('EquipmentTrainingQuizResult', { equipmentId: tt.UUID, trainingSheetId: t.string, - memberNumberProvided: t.number, + memberNumberProvided: t.union([t.number, t.null]), emailProvided: t.union([t.string, t.null]), score: t.number, id: tt.UUID, From 86ccfe9c990e18693a967396270d426652b668f3 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 6 Jul 2024 21:50:49 +0100 Subject: [PATCH 04/38] Restructuring to support handling quiz results with conflicting member number / email --- src/queries/equipment/construct-view-model.ts | 53 ++++++++++++++----- src/queries/equipment/view-model.ts | 1 - .../equipment/get-training-quiz-results.ts | 30 ++--------- 3 files changed, 43 insertions(+), 41 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 8c6ead00..e7228d1a 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -19,12 +19,14 @@ const constructQuizResultViewModel = ( event: EventOfType<'EquipmentTrainingQuizResult'> ): QuizResultViewModel => { return { - email: event.email, + id: event.id, score: event.score, maxScore: event.maxScore, percentage: event.percentage, passed: event.fullMarks, timestamp: DateTime.fromSeconds(event.timestampEpochS), + + email: event.email, }; }; @@ -42,16 +44,41 @@ const getEquipment = ( const getQuizResults = ( events: ReadonlyArray, - equipmentId: string -) => - pipe( - readModels.equipment.getTrainingQuizResults(events)(equipmentId, O.none), - trainingQuizResults => ({ - passed: RA.map(constructQuizResultViewModel)(trainingQuizResults.passed), - all: RA.map(constructQuizResultViewModel)(trainingQuizResults.all), - }), - TE.right + equipment: Equipment +): TE.TaskEither< + FailureWithStatus, + { + quiz_passed_not_trained: ReadonlyArray; + failed_quiz_not_passed: ReadonlyArray; + } +> => { + // Get quiz results for member + email where it matches. + // Get quiz results that don't match. + // Allow dismissing a quiz result. + + + const quizResultEvents = readModels.equipment.getTrainingQuizResults(events)( + equipment.id ); + const members = readModels.members.getAllDetails(events); + + + let member_training_events = { + + } + + let per_s + + +}; +pipe( + readModels.equipment.getTrainingQuizResults(events)(equipmentId, O.none), + trainingQuizResults => ({ + passed: RA.map(constructQuizResultViewModel)(trainingQuizResults.passed), + all: RA.map(constructQuizResultViewModel)(trainingQuizResults.all), + }), + TE.right +); const isSuperUserOrOwnerOfArea = ( events: ReadonlyArray, @@ -81,13 +108,13 @@ export const constructViewModel = TE.right, TE.bind('events', () => deps.getAllEvents()), TE.bind('equipment', ({events}) => getEquipment(events, equipmentId)), - TE.bindW('trainingQuizResults', ({events}) => - getQuizResults(events, equipmentId) - ), TE.bindW('isSuperUserOrOwnerOfArea', ({events, equipment}) => isSuperUserOrOwnerOfArea(events, equipment.areaId, user.memberNumber) ), TE.bindW('isSuperUserOrTrainerOfArea', ({events, equipment}) => isSuperUserOrTrainerOfEquipment(events, equipment, user.memberNumber) + ), + TE.bindW('trainingQuizResults', ({events, equipment}) => + getQuizResults(events, equipment) ) ); diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index fb19aec9..183158e3 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -10,7 +10,6 @@ export type QuizResultViewModel = { percentage: number; passed: boolean; timestamp: DateTime; - previousAttempts: QuizID[]; emailProvided: string; memberNumberProvided: number; diff --git a/src/read-models/equipment/get-training-quiz-results.ts b/src/read-models/equipment/get-training-quiz-results.ts index 53e5b0a1..89aaaf22 100644 --- a/src/read-models/equipment/get-training-quiz-results.ts +++ b/src/read-models/equipment/get-training-quiz-results.ts @@ -1,41 +1,17 @@ import {pipe} from 'fp-ts/lib/function'; import * as RA from 'fp-ts/ReadonlyArray'; -import * as O from 'fp-ts/Option'; import {DomainEvent, isEventOfType} from '../../types'; import {EventOfType} from '../../types/domain-event'; export const getTrainingQuizResults = (events: ReadonlyArray) => ( - equipmentId: string, - trainingSheetId: O.Option - ): { - passed: ReadonlyArray>; - all: ReadonlyArray>; - } => + equipmentId: string + ): ReadonlyArray> => pipe( events, RA.filter(isEventOfType('EquipmentTrainingQuizResult')), RA.filter(event => { - if (O.isSome(trainingSheetId)) { - return ( - event.equipmentId === equipmentId && - event.trainingSheetId === trainingSheetId.value - ); - } return event.equipmentId === equipmentId; - }), - RA.reduce( - { - passed: [] as EventOfType<'EquipmentTrainingQuizResult'>[], - all: [] as EventOfType<'EquipmentTrainingQuizResult'>[], - }, - (result, event) => { - if (event.fullMarks) { - result.passed.push(event); - } - result.all.push(event); - return result; - } - ) + }) ); From 8985ad36efc59efa10f555828f802eaa7717d122 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 6 Jul 2024 22:10:05 +0100 Subject: [PATCH 05/38] Remove now unused --- src/queries/equipment/construct-view-model.ts | 28 ++----------------- 1 file changed, 3 insertions(+), 25 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index e7228d1a..17fe845b 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -11,25 +11,9 @@ import { import {QuizResultViewModel, ViewModel} from './view-model'; import {User} from '../../types'; import {StatusCodes} from 'http-status-codes'; -import {DomainEvent, EventOfType} from '../../types/domain-event'; -import {DateTime} from 'luxon'; +import {DomainEvent} from '../../types/domain-event'; import {Equipment} from '../../read-models/equipment/get'; -const constructQuizResultViewModel = ( - event: EventOfType<'EquipmentTrainingQuizResult'> -): QuizResultViewModel => { - return { - id: event.id, - score: event.score, - maxScore: event.maxScore, - percentage: event.percentage, - passed: event.fullMarks, - timestamp: DateTime.fromSeconds(event.timestampEpochS), - - email: event.email, - }; -}; - const getEquipment = ( events: ReadonlyArray, equipmentId: string @@ -55,21 +39,15 @@ const getQuizResults = ( // Get quiz results for member + email where it matches. // Get quiz results that don't match. // Allow dismissing a quiz result. - const quizResultEvents = readModels.equipment.getTrainingQuizResults(events)( equipment.id ); const members = readModels.members.getAllDetails(events); + const member_training_events = {}; - let member_training_events = { - - } - - let per_s - - + let per_s; }; pipe( readModels.equipment.getTrainingQuizResults(events)(equipmentId, O.none), From 2271e9aa153f76c0775a3c0d4fa8449aa03934c2 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sat, 6 Jul 2024 23:52:42 +0100 Subject: [PATCH 06/38] Separate quiz results with correctly matched member numbers and emails --- src/queries/equipment/construct-view-model.ts | 57 +++++++++++++++++-- src/read-models/members/get-all.ts | 2 +- 2 files changed, 54 insertions(+), 5 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 17fe845b..7a0f1d83 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -9,10 +9,12 @@ import { failureWithStatus, } from '../../types/failure-with-status'; import {QuizResultViewModel, ViewModel} from './view-model'; -import {User} from '../../types'; +import {MemberDetails, User} from '../../types'; import {StatusCodes} from 'http-status-codes'; -import {DomainEvent} from '../../types/domain-event'; +import {DomainEvent, EventOfType} from '../../types/domain-event'; import {Equipment} from '../../read-models/equipment/get'; +import {AllMemberDetails} from '../../read-models/members/get-all'; +import {Logger} from 'pino'; const getEquipment = ( events: ReadonlyArray, @@ -26,7 +28,22 @@ const getEquipment = ( ) ); +const indexMembersByEmail = (byId: AllMemberDetails) => { + return pipe( + Object.values(byId), + RA.reduce( + {} as Record, + (acc, member: MemberDetails) => { + acc[member.email] = member; + member.prevEmails.forEach(email => (acc[email] = member)); + return acc; + } + ) + ); +}; + const getQuizResults = ( + logger: Logger, events: ReadonlyArray, equipment: Equipment ): TE.TaskEither< @@ -44,10 +61,42 @@ const getQuizResults = ( equipment.id ); const members = readModels.members.getAllDetails(events); + const membersByEmail = indexMembersByEmail(members); - const member_training_events = {}; + const member_training_events: Record< + number, + EventOfType<'EquipmentTrainingQuizResult'>[] + > = {}; + const not_matching_member_training_events: EventOfType<'EquipmentTrainingQuizResult'>[] = + []; - let per_s; + for (const quizEntry of events) { + if (quizEntry.type !== 'EquipmentTrainingQuizResult') { + continue; + } + + const memberFoundByNumber = quizEntry.memberNumberProvided + ? members.get(quizEntry.memberNumberProvided) + : undefined; + const memberFoundByEmail = + quizEntry.emailProvided !== null && quizEntry.emailProvided !== undefined + ? membersByEmail[quizEntry.emailProvided] + : undefined; + + if (!memberFoundByNumber && !memberFoundByEmail) { + logger.warn(`Filtering quiz event ${quizEntry.id} as member unknown`); + continue; + } + + if (memberFoundByNumber === memberFoundByEmail) { + if (!member_training_events[memberFoundByNumber!.number]) { + member_training_events[memberFoundByNumber!.number] = []; + } + member_training_events[memberFoundByNumber!.number].push(quizEntry); + } else { + not_matching_member_training_events.push(quizEntry); + } + } }; pipe( readModels.equipment.getTrainingQuizResults(events)(equipmentId, O.none), diff --git a/src/read-models/members/get-all.ts b/src/read-models/members/get-all.ts index 8799ebba..6980e12e 100644 --- a/src/read-models/members/get-all.ts +++ b/src/read-models/members/get-all.ts @@ -10,7 +10,7 @@ import { } from '../../types'; import {pipe} from 'fp-ts/lib/function'; -type AllMemberDetails = Map; +export type AllMemberDetails = Map; export const pertinentEvents = [ 'MemberNumberLinkedToEmail' as const, From 722bf05c45abed8c85e5d379ae902372ab7290cd Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 8 Jul 2024 14:18:08 +0100 Subject: [PATCH 07/38] Thinking about how to structure this - start with low efficiency gathering of all quiz results after updates --- src/queries/equipment/construct-view-model.ts | 153 ++++++++++++------ src/types/domain-event.ts | 8 + 2 files changed, 111 insertions(+), 50 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 7a0f1d83..66a38bf8 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -1,7 +1,6 @@ import {pipe} from 'fp-ts/lib/function'; import {Dependencies} from '../../dependencies'; import * as TE from 'fp-ts/TaskEither'; -import * as O from 'fp-ts/Option'; import * as RA from 'fp-ts/ReadonlyArray'; import {readModels} from '../../read-models'; import { @@ -42,70 +41,124 @@ const indexMembersByEmail = (byId: AllMemberDetails) => { ); }; -const getQuizResults = ( - logger: Logger, +const getQuizEvents = ( events: ReadonlyArray, equipment: Equipment -): TE.TaskEither< - FailureWithStatus, - { - quiz_passed_not_trained: ReadonlyArray; - failed_quiz_not_passed: ReadonlyArray; - } -> => { - // Get quiz results for member + email where it matches. - // Get quiz results that don't match. - // Allow dismissing a quiz result. +) => { + const results: { + [ + index: EventOfType<'EquipmentTrainingQuizResult'>['id'] + ]: EventOfType<'EquipmentTrainingQuizResult'>; + } = {}; + events.forEach(event => { + switch (event.type) { + case 'EquipmentTrainingQuizResult': + if (event.equipmentId === equipment.id) { + results[event.id] = event; + } + break; + case 'EquipmentTrainingQuizEmailUpdated': + if (results[event.quizId]) { + results[event.quizId].emailProvided = event.newEmail; + } + break; + case 'EquipmentTrainingQuizMemberNumberUpdated': + if (results[event.quizId]) { + results[event.quizId].memberNumberProvided = event.newMemberNumber; + } + break; + default: + break; + } + }); + return Object.values(results); +}; - const quizResultEvents = readModels.equipment.getTrainingQuizResults(events)( - equipment.id - ); +const getMemberTrainingEvents = ( + logger: Logger, + events: ReadonlyArray, + equipment: Equipment +) => { const members = readModels.members.getAllDetails(events); const membersByEmail = indexMembersByEmail(members); - const member_training_events: Record< - number, - EventOfType<'EquipmentTrainingQuizResult'>[] - > = {}; - const not_matching_member_training_events: EventOfType<'EquipmentTrainingQuizResult'>[] = - []; + type TrainingEvents = + | EventOfType<'EquipmentTrainingQuizResult'> + | EventOfType<'MemberTrainedOnEquipment'> + | EventOfType<'EquipmentTrainingQuizEmailUpdated'> + | EventOfType<'EquipmentTrainingQuizMemberNumberUpdated'>; - for (const quizEntry of events) { - if (quizEntry.type !== 'EquipmentTrainingQuizResult') { - continue; - } + const memberTrainingEvents: Record = {}; + const orphanedTrainingEvents: TrainingEvents[] = []; + + const quizEvents = getQuizEvents(events, equipment); - const memberFoundByNumber = quizEntry.memberNumberProvided - ? members.get(quizEntry.memberNumberProvided) - : undefined; - const memberFoundByEmail = - quizEntry.emailProvided !== null && quizEntry.emailProvided !== undefined - ? membersByEmail[quizEntry.emailProvided] + for (const event of events) { + if ( + event.type === 'EquipmentTrainingQuizResult' && + event.equipmentId === equipment.id + ) { + const memberFoundByNumber = event.memberNumberProvided + ? members.get(event.memberNumberProvided) : undefined; + const memberFoundByEmail = + event.emailProvided !== null && event.emailProvided !== undefined + ? membersByEmail[event.emailProvided] + : undefined; - if (!memberFoundByNumber && !memberFoundByEmail) { - logger.warn(`Filtering quiz event ${quizEntry.id} as member unknown`); - continue; - } + if (!memberFoundByNumber && !memberFoundByEmail) { + logger.warn(`Filtering quiz event ${event.id} as member unknown`); + continue; + } - if (memberFoundByNumber === memberFoundByEmail) { - if (!member_training_events[memberFoundByNumber!.number]) { - member_training_events[memberFoundByNumber!.number] = []; + if (memberFoundByNumber === memberFoundByEmail) { + if (!memberTrainingEvents[memberFoundByNumber!.number]) { + memberTrainingEvents[memberFoundByNumber!.number] = []; + } + memberTrainingEvents[memberFoundByNumber!.number].push(event); + } else { + orphanedTrainingEvents.push(event); } - member_training_events[memberFoundByNumber!.number].push(quizEntry); - } else { - not_matching_member_training_events.push(quizEntry); } + if ( + event.type === 'MemberTrainedOnEquipment' && + event.equipmentId === equipment.id + ) { + if (!memberTrainingEvents[event.memberNumber]) { + memberTrainingEvents[event.memberNumber] = []; + } + memberTrainingEvents[event.memberNumber].push(event); + } + } + return { + memberTrainingEvents, + orphanedTrainingEvents, + }; +}; + +const getQuizResults = ( + logger: Logger, + events: ReadonlyArray, + equipment: Equipment +): TE.TaskEither< + FailureWithStatus, + { + quizPassedNotTrained: { + matched: ReadonlyArray; + orphaned: ReadonlyArray; + }; + failedQuizNotPassed: { + matched: ReadonlyArray; + orphaned: ReadonlyArray; + }; } +> => { + // Get quiz results for member + email where it matches. + // Get quiz results that don't match. + // Allow dismissing a quiz result. + const {memberTrainingEvents, orphanedTrainingEvents} = + getMemberTrainingEvents(logger, events, equipment); }; -pipe( - readModels.equipment.getTrainingQuizResults(events)(equipmentId, O.none), - trainingQuizResults => ({ - passed: RA.map(constructQuizResultViewModel)(trainingQuizResults.passed), - all: RA.map(constructQuizResultViewModel)(trainingQuizResults.all), - }), - TE.right -); const isSuperUserOrOwnerOfArea = ( events: ReadonlyArray, diff --git a/src/types/domain-event.ts b/src/types/domain-event.ts index 31f86d1c..0084e626 100644 --- a/src/types/domain-event.ts +++ b/src/types/domain-event.ts @@ -91,6 +91,14 @@ export const DomainEvent = t.union([ memberNumber: t.number, newEmail: EmailAddressCodec, }), + eventCodec('EquipmentTrainingQuizMemberNumberUpdated', { + quizId: tt.UUID, + newMemberNumber: t.number, + }), + eventCodec('EquipmentTrainingQuizEmailUpdated', { + quizId: tt.UUID, + newEmail: t.string, + }), ]); export type DomainEvent = t.TypeOf; From a810af00f928ff556983b1079e59c846ec052ec0 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 8 Jul 2024 20:26:29 +0100 Subject: [PATCH 08/38] Get members trained on --- src/queries/equipment/construct-view-model.ts | 95 +++++++++++-------- src/queries/equipment/view-model.ts | 2 + src/read-models/members/get-trained-on.ts | 45 +++++++++ 3 files changed, 103 insertions(+), 39 deletions(-) create mode 100644 src/read-models/members/get-trained-on.ts diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 66a38bf8..720b8096 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -51,6 +51,7 @@ const getQuizEvents = ( ]: EventOfType<'EquipmentTrainingQuizResult'>; } = {}; events.forEach(event => { + // Requires events to be provided in order. switch (event.type) { case 'EquipmentTrainingQuizResult': if (event.equipmentId === equipment.id) { @@ -74,6 +75,53 @@ const getQuizEvents = ( return Object.values(results); }; +const reduceToLatestQuizResultByMember = ( + logger: Logger, + members: AllMemberDetails, + membersByEmail: Record, + quizResults: ReturnType +) => { + const memberQuizResults: Record = {}; + const orphanedQuizResults: TrainingEvents[] = []; + for (const quizResult of quizResults) { + const memberFoundByNumber = quizResult.memberNumberProvided + ? members.get(quizResult.memberNumberProvided) + : undefined; + const memberFoundByEmail = + quizResult.emailProvided !== null && + quizResult.emailProvided !== undefined + ? membersByEmail[quizResult.emailProvided] + : undefined; + + if (!memberFoundByNumber && !memberFoundByEmail) { + logger.warn(`Filtering quiz event ${quizResult.id} as member unknown`); + continue; + } + + if (memberFoundByNumber === memberFoundByEmail) { + if (!memberTrainingEvents[memberFoundByNumber!.number]) { + memberTrainingEvents[memberFoundByNumber!.number] = []; + } + memberTrainingEvents[memberFoundByNumber!.number].push(quizResult); + } else { + orphanedTrainingEvents.push(quizResult); + } + } +}; + +const getMembersCurrentlyTrained = ( + events: ReadonlyArray, + equipment: Equipment +) => { + const trained: Record = {}; + for (const event of events) { + if (event.type !== 'MemberTrainedOnEquipment' || event.equipmentId !== equipment.id) { + continue; + } + trained.add(event.) + } +} + const getMemberTrainingEvents = ( logger: Logger, events: ReadonlyArray, @@ -88,48 +136,17 @@ const getMemberTrainingEvents = ( | EventOfType<'EquipmentTrainingQuizEmailUpdated'> | EventOfType<'EquipmentTrainingQuizMemberNumberUpdated'>; - const memberTrainingEvents: Record = {}; - const orphanedTrainingEvents: TrainingEvents[] = []; + const quizEvents = getQuizEvents(events, equipment); + const trainedMembers = getTRai + const memberResults = reduceToLatestQuizResultByMember( + logger, + members, + membersByEmail, + quizEvents + ); - for (const event of events) { - if ( - event.type === 'EquipmentTrainingQuizResult' && - event.equipmentId === equipment.id - ) { - const memberFoundByNumber = event.memberNumberProvided - ? members.get(event.memberNumberProvided) - : undefined; - const memberFoundByEmail = - event.emailProvided !== null && event.emailProvided !== undefined - ? membersByEmail[event.emailProvided] - : undefined; - - if (!memberFoundByNumber && !memberFoundByEmail) { - logger.warn(`Filtering quiz event ${event.id} as member unknown`); - continue; - } - - if (memberFoundByNumber === memberFoundByEmail) { - if (!memberTrainingEvents[memberFoundByNumber!.number]) { - memberTrainingEvents[memberFoundByNumber!.number] = []; - } - memberTrainingEvents[memberFoundByNumber!.number].push(event); - } else { - orphanedTrainingEvents.push(event); - } - } - if ( - event.type === 'MemberTrainedOnEquipment' && - event.equipmentId === equipment.id - ) { - if (!memberTrainingEvents[event.memberNumber]) { - memberTrainingEvents[event.memberNumber] = []; - } - memberTrainingEvents[event.memberNumber].push(event); - } - } return { memberTrainingEvents, orphanedTrainingEvents, diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index 183158e3..f8b41cbd 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -18,6 +18,8 @@ export type QuizResultViewModel = { emailMemberNumber: number | null; memberDetailsMatch: boolean; + + otherAttempts: ReadonlyArray; }; export type ViewModel = { diff --git a/src/read-models/members/get-trained-on.ts b/src/read-models/members/get-trained-on.ts new file mode 100644 index 00000000..c94aaa8b --- /dev/null +++ b/src/read-models/members/get-trained-on.ts @@ -0,0 +1,45 @@ +import {DomainEvent} from '../../types'; + +export type TrainedInfo = { + when: Date; + by: number | null; + prev: ReadonlyArray<{ + when: Date; + by: number | null; + }>; +}; + +export const getMembersTrainedOn = + (equipmentId: string) => + (events: ReadonlyArray): ReadonlyMap => { + // Note that currently you cannot revoke training. + + const trained: Map = new Map(); + for (const event of events) { + if ( + event.type !== 'MemberTrainedOnEquipment' || + event.equipmentId !== equipmentId + ) { + continue; + } + const current = trained.get(event.memberNumber); + if (current) { + trained.set(event.memberNumber, { + when: event.recordedAt, + by: event.trainedByMemberNumber, + prev: current.prev.concat([ + { + when: current.when, + by: current.by, + }, + ]), + }); + } + trained.set(event.memberNumber, { + when: event.recordedAt, + by: event.trainedByMemberNumber, + prev: [], + }); + } + return trained; + }; From 9268b7a0c617c27e926b9151cd7461499714f1c8 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 8 Jul 2024 20:27:04 +0100 Subject: [PATCH 09/38] Query should be under equipment section --- src/read-models/{members => equipment}/get-trained-on.ts | 0 1 file changed, 0 insertions(+), 0 deletions(-) rename src/read-models/{members => equipment}/get-trained-on.ts (100%) diff --git a/src/read-models/members/get-trained-on.ts b/src/read-models/equipment/get-trained-on.ts similarity index 100% rename from src/read-models/members/get-trained-on.ts rename to src/read-models/equipment/get-trained-on.ts From d5b93e912b4e74794fe51699c1e511e3d5295c5c Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 8 Jul 2024 23:28:44 +0100 Subject: [PATCH 10/38] Determine quiz passed not trained and failed quiz not trained --- src/queries/equipment/construct-view-model.ts | 163 +++++++++++------- src/queries/equipment/view-model.ts | 18 +- 2 files changed, 104 insertions(+), 77 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 720b8096..35534b54 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -14,6 +14,8 @@ import {DomainEvent, EventOfType} from '../../types/domain-event'; import {Equipment} from '../../read-models/equipment/get'; import {AllMemberDetails} from '../../read-models/members/get-all'; import {Logger} from 'pino'; +import {getMembersTrainedOn} from '../../read-models/equipment/get-trained-on'; +import {DateTime} from 'luxon'; const getEquipment = ( events: ReadonlyArray, @@ -81,75 +83,82 @@ const reduceToLatestQuizResultByMember = ( membersByEmail: Record, quizResults: ReturnType ) => { - const memberQuizResults: Record = {}; - const orphanedQuizResults: TrainingEvents[] = []; + const memberQuizResults: Record = {}; + const unknownMemberQuizResults: QuizResultViewModel[] = []; for (const quizResult of quizResults) { const memberFoundByNumber = quizResult.memberNumberProvided ? members.get(quizResult.memberNumberProvided) : undefined; const memberFoundByEmail = - quizResult.emailProvided !== null && - quizResult.emailProvided !== undefined + quizResult.emailProvided !== null ? membersByEmail[quizResult.emailProvided] : undefined; - if (!memberFoundByNumber && !memberFoundByEmail) { - logger.warn(`Filtering quiz event ${quizResult.id} as member unknown`); - continue; - } - - if (memberFoundByNumber === memberFoundByEmail) { - if (!memberTrainingEvents[memberFoundByNumber!.number]) { - memberTrainingEvents[memberFoundByNumber!.number] = []; + if ( + memberFoundByNumber === memberFoundByEmail && + memberFoundByNumber !== undefined + ) { + const existing = memberQuizResults[memberFoundByNumber.number]; + if (!existing) { + memberQuizResults[memberFoundByNumber.number] = { + id: quizResult.id, + score: quizResult.score, + maxScore: quizResult.maxScore, + percentage: quizResult.percentage, + passed: quizResult.fullMarks, + timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), + memberNumber: memberFoundByNumber.number, + otherAttempts: [], + }; + continue; + } + if (existing.passed) { + if (quizResult.fullMarks) { + // Overwrite. + memberQuizResults[memberFoundByNumber.number] = { + id: quizResult.id, + score: quizResult.score, + maxScore: quizResult.maxScore, + percentage: quizResult.percentage, + passed: quizResult.fullMarks, + timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), + memberNumber: memberFoundByNumber.number, + otherAttempts: [existing.id].concat(existing.otherAttempts), + }; + } else { + memberQuizResults[memberFoundByNumber.number].otherAttempts = + memberQuizResults[memberFoundByNumber.number].otherAttempts.concat([ + quizResult.id, + ]); + } + } else { + memberQuizResults[memberFoundByNumber.number] = { + id: quizResult.id, + score: quizResult.score, + maxScore: quizResult.maxScore, + percentage: quizResult.percentage, + passed: quizResult.fullMarks, + timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), + memberNumber: memberFoundByNumber.number, + otherAttempts: [existing.id].concat(existing.otherAttempts), + }; } - memberTrainingEvents[memberFoundByNumber!.number].push(quizResult); - } else { - orphanedTrainingEvents.push(quizResult); - } - } -}; - -const getMembersCurrentlyTrained = ( - events: ReadonlyArray, - equipment: Equipment -) => { - const trained: Record = {}; - for (const event of events) { - if (event.type !== 'MemberTrainedOnEquipment' || event.equipmentId !== equipment.id) { continue; } - trained.add(event.) + unknownMemberQuizResults.push({ + id: quizResult.id, + score: quizResult.score, + maxScore: quizResult.maxScore, + percentage: quizResult.percentage, + passed: quizResult.fullMarks, + timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), + memberNumber: memberFoundByNumber!.number, + otherAttempts: [], + }); } -} - -const getMemberTrainingEvents = ( - logger: Logger, - events: ReadonlyArray, - equipment: Equipment -) => { - const members = readModels.members.getAllDetails(events); - const membersByEmail = indexMembersByEmail(members); - - type TrainingEvents = - | EventOfType<'EquipmentTrainingQuizResult'> - | EventOfType<'MemberTrainedOnEquipment'> - | EventOfType<'EquipmentTrainingQuizEmailUpdated'> - | EventOfType<'EquipmentTrainingQuizMemberNumberUpdated'>; - - - - const quizEvents = getQuizEvents(events, equipment); - const trainedMembers = getTRai - const memberResults = reduceToLatestQuizResultByMember( - logger, - members, - membersByEmail, - quizEvents - ); - return { - memberTrainingEvents, - orphanedTrainingEvents, + memberQuizResults, + unknownMemberQuizResults, }; }; @@ -161,20 +170,40 @@ const getQuizResults = ( FailureWithStatus, { quizPassedNotTrained: { - matched: ReadonlyArray; - orphaned: ReadonlyArray; + knownMember: ReadonlyArray; + unknownMember: ReadonlyArray; }; - failedQuizNotPassed: { - matched: ReadonlyArray; - orphaned: ReadonlyArray; + failedQuizNotTrained: { + knownMember: ReadonlyArray; }; } > => { - // Get quiz results for member + email where it matches. - // Get quiz results that don't match. - // Allow dismissing a quiz result. - const {memberTrainingEvents, orphanedTrainingEvents} = - getMemberTrainingEvents(logger, events, equipment); + const members = readModels.members.getAllDetails(events); + const membersByEmail = indexMembersByEmail(members); + const quizEvents = getQuizEvents(events, equipment); + const trainedMembers = getMembersTrainedOn(equipment.id)(events); + const memberResults = reduceToLatestQuizResultByMember( + logger, + members, + membersByEmail, + quizEvents + ); + + return TE.right({ + quizPassedNotTrained: { + knownMember: Object.values(memberResults.memberQuizResults).filter( + r => r.passed && !trainedMembers.has(r.memberNumber) + ), + unknownMember: memberResults.unknownMemberQuizResults.filter( + r => r.passed + ), + }, + failedQuizNotTrained: { + knownMember: Object.values(memberResults.memberQuizResults).filter( + r => !r.passed && !trainedMembers.has(r.memberNumber) + ), + }, + }); }; const isSuperUserOrOwnerOfArea = ( @@ -212,6 +241,6 @@ export const constructViewModel = isSuperUserOrTrainerOfEquipment(events, equipment, user.memberNumber) ), TE.bindW('trainingQuizResults', ({events, equipment}) => - getQuizResults(events, equipment) + getQuizResults(deps.logger, events, equipment) ) ); diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index f8b41cbd..cac5aa27 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -11,13 +11,7 @@ export type QuizResultViewModel = { passed: boolean; timestamp: DateTime; - emailProvided: string; - memberNumberProvided: number; - - memberNumberFound: boolean; - emailMemberNumber: number | null; - - memberDetailsMatch: boolean; + memberNumber: number; otherAttempts: ReadonlyArray; }; @@ -33,8 +27,12 @@ export type ViewModel = { trainedMembers: ReadonlyArray; }; trainingQuizResults: { - // Each member should only appear in one of these to avoid confusion. - quiz_passed_not_trained: ReadonlyArray; - failed_quiz_not_passed: ReadonlyArray; + quizPassedNotTrained: { + knownMember: ReadonlyArray; + unknownMember: ReadonlyArray; + }; + failedQuizNotTrained: { + knownMember: ReadonlyArray; + }; }; }; From 71d35421a8b73e89a46a4dc76e169b4592579e3a Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Tue, 9 Jul 2024 23:44:00 +0100 Subject: [PATCH 11/38] Update rendering quiz results --- src/queries/equipment/render.ts | 88 ++++++++++++++++++++++++++--- src/queries/equipment/view-model.ts | 14 ++++- 2 files changed, 92 insertions(+), 10 deletions(-) diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 85ba30da..0f8d6112 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -82,32 +82,35 @@ Handlebars.registerPartial( ); // TODO -// 1. Realistically people only care about training quiz results for people who have passed the quiz and aren't already signed off. // 2. Dates aren't displayed using the users locale. Handlebars.registerPartial( 'training_quiz_results_table', ` + + - {{#each results}} + {{#each results.knownMember}} {{#if this.passed}} {{else}} {{/if}} + - + + {{else}} -

No one is waiting for training

+

{{empty_msg}}

{{/each}}
Timestamp Email Score
{{display_date this.timestamp}}{{this.email}}{{member_number this.memberNumber}} {{this.score}} / {{this.maxScore}} ({{this.percentage}}%)
` @@ -118,11 +121,78 @@ Handlebars.registerPartial( `

Training Quiz Results

Waiting for Training

-{{#with trainingQuizResults}} - {{> training_quiz_results_table results=passed }} -

All Results

- {{> training_quiz_results_table results=all }} -{{/with}} + + + + + + + + + + {{#each trainingQuizResults.quizPassedNotTrained.knownMember}} + + + + + + + + + {{else}} +

No one is waiting for training

+ {{/each}} +
TimestampMember NumberScoreActions
{{display_date this.timestamp}}{{member_number this.memberNumber}} + {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) +
+{{#if trainingQuizResults.quizPassedNotTrained.unknownMember}} +

Waiting for Training - Unknown Member

+

Quizes completed by members without matching email and member numbers

+ + + + + + + + + {{#each trainingQuizResults.quizPassedNotTrained.unknownMember}} + + + + + + + + {{/each}} +
TimestampMember Number ProvidedEmail ProvidedScore
{{display_date this.timestamp}}{{member_number this.memberNumberProvided}}{{this.emailProvided}} + {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) +
+{{/if}} +

Failed quizes

+

Members who haven't passed (but have attempted) the quiz

+ + + + + + + + + {{#each trainingQuizResults.failedQuizNotTrained.knownMember}} + + + + + + + + {{else}} +

No failed quiz attempts

+ {{/each}} +
TimestampMember NumberScore
{{display_date this.timestamp}}{{member_number this.memberNumber}} + {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) +
` ); diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index cac5aa27..da802803 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -16,6 +16,18 @@ export type QuizResultViewModel = { otherAttempts: ReadonlyArray; }; +export type QuizResultUnknownMemberViewModel = { + id: QuizID; + score: number; + maxScore: number; + percentage: number; + passed: boolean; + timestamp: DateTime; + + memberNumberProvided: number; + emailProvided: string; +}; + export type ViewModel = { user: User; isSuperUserOrOwnerOfArea: boolean; @@ -29,7 +41,7 @@ export type ViewModel = { trainingQuizResults: { quizPassedNotTrained: { knownMember: ReadonlyArray; - unknownMember: ReadonlyArray; + unknownMember: ReadonlyArray; }; failedQuizNotTrained: { knownMember: ReadonlyArray; From e7e388a64555abd3f14f08be0930b76cf2fba21d Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Tue, 9 Jul 2024 23:48:10 +0100 Subject: [PATCH 12/38] Render email/number provided --- src/queries/equipment/construct-view-model.ts | 14 +++++++++----- src/queries/equipment/render.ts | 8 ++++++-- src/queries/equipment/view-model.ts | 4 ++-- 3 files changed, 17 insertions(+), 9 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 35534b54..d8c30a72 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -7,7 +7,11 @@ import { FailureWithStatus, failureWithStatus, } from '../../types/failure-with-status'; -import {QuizResultViewModel, ViewModel} from './view-model'; +import { + QuizResultUnknownMemberViewModel, + QuizResultViewModel, + ViewModel, +} from './view-model'; import {MemberDetails, User} from '../../types'; import {StatusCodes} from 'http-status-codes'; import {DomainEvent, EventOfType} from '../../types/domain-event'; @@ -84,7 +88,7 @@ const reduceToLatestQuizResultByMember = ( quizResults: ReturnType ) => { const memberQuizResults: Record = {}; - const unknownMemberQuizResults: QuizResultViewModel[] = []; + const unknownMemberQuizResults: QuizResultUnknownMemberViewModel[] = []; for (const quizResult of quizResults) { const memberFoundByNumber = quizResult.memberNumberProvided ? members.get(quizResult.memberNumberProvided) @@ -152,8 +156,8 @@ const reduceToLatestQuizResultByMember = ( percentage: quizResult.percentage, passed: quizResult.fullMarks, timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), - memberNumber: memberFoundByNumber!.number, - otherAttempts: [], + memberNumberProvided: quizResult.memberNumberProvided, + emailProvided: quizResult.emailProvided, }); } return { @@ -171,7 +175,7 @@ const getQuizResults = ( { quizPassedNotTrained: { knownMember: ReadonlyArray; - unknownMember: ReadonlyArray; + unknownMember: ReadonlyArray; }; failedQuizNotTrained: { knownMember: ReadonlyArray; diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index 0f8d6112..a8519463 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -160,8 +160,12 @@ Handlebars.registerPartial( {{this.id}} {{display_date this.timestamp}} - {{member_number this.memberNumberProvided}} - {{this.emailProvided}} + {{#if this.memberNumberProvided}} + {{member_number this.memberNumberProvided}} + {{else}} + {{optional_detail this.memberNumberProvided}} + {{/if}} + {{optional_detail this.emailProvided}} {{this.score}} / {{this.maxScore}} ({{this.percentage}}%) diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index da802803..6b3f8a50 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -24,8 +24,8 @@ export type QuizResultUnknownMemberViewModel = { passed: boolean; timestamp: DateTime; - memberNumberProvided: number; - emailProvided: string; + memberNumberProvided: number | null; + emailProvided: string | null; }; export type ViewModel = { From 6d45e04beba88d2a687c8e6245eea178eadd17b1 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Wed, 10 Jul 2024 00:23:25 +0100 Subject: [PATCH 13/38] Refined rules for quiz results inc. support for member number or email missing --- src/queries/equipment/construct-view-model.ts | 94 ++++++++----------- 1 file changed, 40 insertions(+), 54 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index d8c30a72..2b29e8b9 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -2,6 +2,7 @@ import {pipe} from 'fp-ts/lib/function'; import {Dependencies} from '../../dependencies'; import * as TE from 'fp-ts/TaskEither'; import * as RA from 'fp-ts/ReadonlyArray'; +import * as O from 'fp-ts/Option'; import {readModels} from '../../read-models'; import { FailureWithStatus, @@ -81,6 +82,30 @@ const getQuizEvents = ( return Object.values(results); }; +const updateQuizResults = ( + memberQuizResults: Record, + member: MemberDetails, + quizResult: EventOfType<'EquipmentTrainingQuizResult'> +) => { + const existing = memberQuizResults[member.number]; + if (quizResult.fullMarks || !existing || !existing.passed) { + memberQuizResults[member.number] = { + id: quizResult.id, + score: quizResult.score, + maxScore: quizResult.maxScore, + percentage: quizResult.percentage, + passed: quizResult.fullMarks, + timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), + memberNumber: member.number, + otherAttempts: existing + ? [existing.id].concat(existing.otherAttempts) + : [], + }; + return; + } + existing.otherAttempts = existing.otherAttempts.concat([quizResult.id]); +}; + const reduceToLatestQuizResultByMember = ( logger: Logger, members: AllMemberDetails, @@ -90,63 +115,24 @@ const reduceToLatestQuizResultByMember = ( const memberQuizResults: Record = {}; const unknownMemberQuizResults: QuizResultUnknownMemberViewModel[] = []; for (const quizResult of quizResults) { - const memberFoundByNumber = quizResult.memberNumberProvided - ? members.get(quizResult.memberNumberProvided) - : undefined; - const memberFoundByEmail = - quizResult.emailProvided !== null - ? membersByEmail[quizResult.emailProvided] - : undefined; + const memberNumber = O.fromNullable(quizResult.memberNumberProvided); + const email = O.fromNullable(quizResult.emailProvided); + + const needToMatch: O.Option[] = []; + if (O.isSome(memberNumber)) { + needToMatch.push(O.fromNullable(members.get(memberNumber.value))); + } + if (O.isSome(email)) { + needToMatch.push(O.fromNullable(membersByEmail[email.value])); + } if ( - memberFoundByNumber === memberFoundByEmail && - memberFoundByNumber !== undefined + (needToMatch.length === 1 && O.isSome(needToMatch[0])) || + (needToMatch.length === 2 && + O.isSome(needToMatch[0]) && + needToMatch[0] === needToMatch[1]) ) { - const existing = memberQuizResults[memberFoundByNumber.number]; - if (!existing) { - memberQuizResults[memberFoundByNumber.number] = { - id: quizResult.id, - score: quizResult.score, - maxScore: quizResult.maxScore, - percentage: quizResult.percentage, - passed: quizResult.fullMarks, - timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), - memberNumber: memberFoundByNumber.number, - otherAttempts: [], - }; - continue; - } - if (existing.passed) { - if (quizResult.fullMarks) { - // Overwrite. - memberQuizResults[memberFoundByNumber.number] = { - id: quizResult.id, - score: quizResult.score, - maxScore: quizResult.maxScore, - percentage: quizResult.percentage, - passed: quizResult.fullMarks, - timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), - memberNumber: memberFoundByNumber.number, - otherAttempts: [existing.id].concat(existing.otherAttempts), - }; - } else { - memberQuizResults[memberFoundByNumber.number].otherAttempts = - memberQuizResults[memberFoundByNumber.number].otherAttempts.concat([ - quizResult.id, - ]); - } - } else { - memberQuizResults[memberFoundByNumber.number] = { - id: quizResult.id, - score: quizResult.score, - maxScore: quizResult.maxScore, - percentage: quizResult.percentage, - passed: quizResult.fullMarks, - timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), - memberNumber: memberFoundByNumber.number, - otherAttempts: [existing.id].concat(existing.otherAttempts), - }; - } + updateQuizResults(memberQuizResults, needToMatch[0].value, quizResult); continue; } unknownMemberQuizResults.push({ From ddb31ad6e4b5f68b7cd6db81ba7f5a31a825cb0a Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Wed, 10 Jul 2024 23:22:48 +0100 Subject: [PATCH 14/38] Type fixes --- .../equipment/register-training-sheet-quiz-result.ts | 3 ++- tests/data/google_sheet_data.ts | 1 + tests/training-sheets/process-events.test.ts | 6 ++++-- 3 files changed, 7 insertions(+), 3 deletions(-) diff --git a/src/commands/equipment/register-training-sheet-quiz-result.ts b/src/commands/equipment/register-training-sheet-quiz-result.ts index ad2606a3..f4d094d7 100644 --- a/src/commands/equipment/register-training-sheet-quiz-result.ts +++ b/src/commands/equipment/register-training-sheet-quiz-result.ts @@ -12,7 +12,8 @@ const codec = t.strict({ equipmentId: tt.UUID, trainingSheetId: t.string, id: tt.UUID, - email: t.string, + emailProvided: t.string, + memberNumberProvided: t.number, score: t.number, maxScore: t.number, percentage: t.number, diff --git a/tests/data/google_sheet_data.ts b/tests/data/google_sheet_data.ts index 9f385e1f..c8ab4e40 100644 --- a/tests/data/google_sheet_data.ts +++ b/tests/data/google_sheet_data.ts @@ -13,6 +13,7 @@ export const METAL_LATHE = { ) as sheets_v4.Schema$Spreadsheet, // Manually parsed data for testing: email: 'test@makespace.com', + memberNumber: 1234, score: 13, maxScore: 14, percentage: 93, diff --git a/tests/training-sheets/process-events.test.ts b/tests/training-sheets/process-events.test.ts index a9e938ef..4db6b8c5 100644 --- a/tests/training-sheets/process-events.test.ts +++ b/tests/training-sheets/process-events.test.ts @@ -108,7 +108,8 @@ describe('Training sheets worker', () => { type: 'EquipmentTrainingQuizResult', equipmentId: addEquipment.id, trainingSheetId: gsheetData.METAL_LATHE.data.spreadsheetId!, - email: gsheetData.METAL_LATHE.email, + emailProvided: gsheetData.METAL_LATHE.email, + memberNumberProvided: gsheetData.METAL_LATHE.memberNumber, score: gsheetData.METAL_LATHE.score, maxScore: gsheetData.METAL_LATHE.maxScore, percentage: gsheetData.METAL_LATHE.percentage, @@ -125,7 +126,8 @@ describe('Training sheets worker', () => { await framework.commands.equipment.trainingSheetQuizResult({ id: faker.string.uuid() as UUID, equipmentId: addEquipment.id, - email: gsheetData.METAL_LATHE.email, + emailProvided: gsheetData.METAL_LATHE.email, + memberNumberProvided: gsheetData.METAL_LATHE.memberNumber, trainingSheetId: gsheetData.METAL_LATHE.data.spreadsheetId!, score: gsheetData.METAL_LATHE.score, maxScore: gsheetData.METAL_LATHE.maxScore, From 1418befe9197d6f8cc2b0ea7ec018d3083a4b05c Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Wed, 10 Jul 2024 23:24:58 +0100 Subject: [PATCH 15/38] Fix populate local dev --- scripts/populate-local-dev.sh | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/scripts/populate-local-dev.sh b/scripts/populate-local-dev.sh index 6ce45dc7..a72af8f0 100755 --- a/scripts/populate-local-dev.sh +++ b/scripts/populate-local-dev.sh @@ -25,14 +25,14 @@ curl -X POST -H 'Authorization: Bearer secret' -H 'Content-Type: application/jso http://localhost:8080/api/equipment/add-training-sheet curl -X POST -H 'Authorization: Bearer secret' -H 'Content-Type: application/json' \ - --data '{"equipmentId": "4224ee94-09b0-47d4-ae60-fac46b8ca93e","trainingSheetId": "fakeTrainingSheetId","id": "2d0e6174-a827-4331-9dc2-ffb05ea863c3","email": "finn.flatcoat@dog.co.uk","score": 13,"maxScore": 20,"percentage": 65,"fullMarks": false,"timestampEpochS": 1718411504,"quizAnswers": {}}' \ + --data '{"equipmentId": "4224ee94-09b0-47d4-ae60-fac46b8ca93e","trainingSheetId": "fakeTrainingSheetId","id": "2d0e6174-a827-4331-9dc2-ffb05ea863c3","emailProvided": "finn.flatcoat@dog.co.uk","memberNumberProvided":1234,"score": 13,"maxScore": 20,"percentage": 65,"fullMarks": false,"timestampEpochS": 1718411504,"quizAnswers": {}}' \ http://localhost:8080/api/equipment/add-training-quiz-result curl -X POST -H 'Authorization: Bearer secret' -H 'Content-Type: application/json' \ - --data '{"equipmentId": "4224ee94-09b0-47d4-ae60-fac46b8ca93e","trainingSheetId": "fakeTrainingSheetId","id": "2d0e6174-a827-4331-9dc2-ffb05ea863c3","email": "finn.flatcoat@dog.co.uk","score": 20,"maxScore": 20,"percentage": 100,"fullMarks": true,"timestampEpochS": 1718413504,"quizAnswers": {}}' \ + --data '{"equipmentId": "4224ee94-09b0-47d4-ae60-fac46b8ca93e","trainingSheetId": "fakeTrainingSheetId","id": "2d0e6174-a827-4331-9dc2-ffb05ea863c3","emailProvided": "finn.flatcoat@dog.co.uk","memberNumberProvided":1234,"score": 20,"maxScore": 20,"percentage": 100,"fullMarks": true,"timestampEpochS": 1718413504,"quizAnswers": {}}' \ http://localhost:8080/api/equipment/add-training-quiz-result # Demonstrates html injection on the equipment page. curl -X POST -H 'Authorization: Bearer secret' -H 'Content-Type: application/json' \ - --data '{"equipmentId": "4224ee94-09b0-47d4-ae60-fac46b8ca93e","trainingSheetId": "fakeTrainingSheetId","id": "2d0e6174-a827-4331-9dc2-ffb05ea863c3","email": "

myemail.com

","score": 20,"maxScore": 20,"percentage": 100,"fullMarks": true,"timestampEpochS": 1718413504,"quizAnswers": {}}' \ + --data '{"equipmentId": "4224ee94-09b0-47d4-ae60-fac46b8ca93e","trainingSheetId": "fakeTrainingSheetId","id": "2d0e6174-a827-4331-9dc2-ffb05ea863c3","emailProvided":"

myemail.com

","memberNumberProvided":1234,"score": 20,"maxScore": 20,"percentage": 100,"fullMarks": true,"timestampEpochS": 1718413504,"quizAnswers": {}}' \ http://localhost:8080/api/equipment/add-training-quiz-result From 7dee6aab7fda959014e6a16ffc87e62bae74a265 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Wed, 10 Jul 2024 23:50:20 +0100 Subject: [PATCH 16/38] Fix equal quiz results not being treated as such --- src/training-sheets/events.ts | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/training-sheets/events.ts b/src/training-sheets/events.ts index b47ce4c4..b3af34f9 100644 --- a/src/training-sheets/events.ts +++ b/src/training-sheets/events.ts @@ -1,12 +1,23 @@ import {Eq} from 'fp-ts/lib/Eq'; import {EventOfType} from '../types/domain-event'; +import {getEq} from 'fp-ts/lib/ReadonlyRecord'; +import {string} from 'fp-ts'; export type QzEvent = EventOfType<'EquipmentTrainingQuizResult'>; export type RegEvent = EventOfType<'EquipmentTrainingSheetRegistered'>; +export const NullableStringEq: Eq = { + equals(x: string | null, y: string | null) { + if (x === null || y === null) { + return x === y; + } + return string.Eq.equals(x, y); + }, +}; + export const QzEventDuplicate: Eq = { equals: (a: QzEvent, b: QzEvent) => - a.quizAnswers === b.quizAnswers && + getEq(NullableStringEq).equals(a.quizAnswers, b.quizAnswers) && a.timestampEpochS === b.timestampEpochS && a.trainingSheetId === b.trainingSheetId, }; From 3489de50bf785ecc773e738c52c2288877a7688a Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Thu, 11 Jul 2024 00:15:28 +0100 Subject: [PATCH 17/38] Test quiz result inequality --- .../register-training-sheet-quiz-result.ts | 2 +- src/queries/equipment/view-model.ts | 2 +- src/read-models/equipment/get-trained-on.ts | 2 +- src/training-sheets/google.ts | 2 +- src/training-sheets/training-sheets-worker.ts | 2 +- src/types/nullable-string.ts | 11 +++ .../events.ts => types/qz-event.ts} | 13 +--- tests/types/nullable-string-eq.test.ts | 16 +++++ tests/types/qz-event.test.ts | 72 +++++++++++++++++++ 9 files changed, 106 insertions(+), 16 deletions(-) create mode 100644 src/types/nullable-string.ts rename src/{training-sheets/events.ts => types/qz-event.ts} (61%) create mode 100644 tests/types/nullable-string-eq.test.ts create mode 100644 tests/types/qz-event.test.ts diff --git a/src/commands/equipment/register-training-sheet-quiz-result.ts b/src/commands/equipment/register-training-sheet-quiz-result.ts index f4d094d7..e15a2132 100644 --- a/src/commands/equipment/register-training-sheet-quiz-result.ts +++ b/src/commands/equipment/register-training-sheet-quiz-result.ts @@ -6,7 +6,7 @@ import * as RA from 'fp-ts/ReadonlyArray'; import {Command} from '../command'; import {isAdminOrSuperUser} from '../is-admin-or-super-user'; import {pipe} from 'fp-ts/lib/function'; -import {QzEventDuplicate} from '../../training-sheets/events'; +import {QzEventDuplicate} from '../../types/qz-event'; const codec = t.strict({ equipmentId: tt.UUID, diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index 6b3f8a50..fe612e4f 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -1,7 +1,7 @@ import {DateTime} from 'luxon'; import {User} from '../../types'; -export type QuizID = string; +type QuizID = string; export type QuizResultViewModel = { id: QuizID; diff --git a/src/read-models/equipment/get-trained-on.ts b/src/read-models/equipment/get-trained-on.ts index c94aaa8b..cc6744a9 100644 --- a/src/read-models/equipment/get-trained-on.ts +++ b/src/read-models/equipment/get-trained-on.ts @@ -1,6 +1,6 @@ import {DomainEvent} from '../../types'; -export type TrainedInfo = { +type TrainedInfo = { when: Date; by: number | null; prev: ReadonlyArray<{ diff --git a/src/training-sheets/google.ts b/src/training-sheets/google.ts index 7200c351..5d03fda0 100644 --- a/src/training-sheets/google.ts +++ b/src/training-sheets/google.ts @@ -8,7 +8,7 @@ import {sheets_v4} from 'googleapis'; import {v4} from 'uuid'; import {UUID} from 'io-ts-types'; import {DateTime} from 'luxon'; -import {QzEvent} from './events'; +import {QzEvent} from '../types/qz-event'; // Bounds to prevent clearly broken parsing. const MIN_RECOGNISED_MEMBER_NUMBER = 0; diff --git a/src/training-sheets/training-sheets-worker.ts b/src/training-sheets/training-sheets-worker.ts index b3d57c84..218c2f51 100644 --- a/src/training-sheets/training-sheets-worker.ts +++ b/src/training-sheets/training-sheets-worker.ts @@ -16,7 +16,7 @@ import { } from '../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {accumBy, lastBy} from '../util'; -import {QzEvent, QzEventDuplicate, RegEvent} from './events'; +import {QzEvent, QzEventDuplicate, RegEvent} from '../types/qz-event'; import {extractGoogleSheetData} from './google'; const byEquipmentId: Ord = pipe( diff --git a/src/types/nullable-string.ts b/src/types/nullable-string.ts new file mode 100644 index 00000000..3648a9c1 --- /dev/null +++ b/src/types/nullable-string.ts @@ -0,0 +1,11 @@ +import {Eq} from 'fp-ts/lib/Eq'; +import {string} from 'fp-ts'; + +export const NullableStringEq: Eq = { + equals(x: string | null, y: string | null) { + if (x === null || y === null) { + return x === y; + } + return string.Eq.equals(x, y); + }, +}; diff --git a/src/training-sheets/events.ts b/src/types/qz-event.ts similarity index 61% rename from src/training-sheets/events.ts rename to src/types/qz-event.ts index b3af34f9..f93f38cc 100644 --- a/src/training-sheets/events.ts +++ b/src/types/qz-event.ts @@ -1,20 +1,11 @@ import {Eq} from 'fp-ts/lib/Eq'; -import {EventOfType} from '../types/domain-event'; +import {EventOfType} from './domain-event'; import {getEq} from 'fp-ts/lib/ReadonlyRecord'; -import {string} from 'fp-ts'; +import {NullableStringEq} from './nullable-string'; export type QzEvent = EventOfType<'EquipmentTrainingQuizResult'>; export type RegEvent = EventOfType<'EquipmentTrainingSheetRegistered'>; -export const NullableStringEq: Eq = { - equals(x: string | null, y: string | null) { - if (x === null || y === null) { - return x === y; - } - return string.Eq.equals(x, y); - }, -}; - export const QzEventDuplicate: Eq = { equals: (a: QzEvent, b: QzEvent) => getEq(NullableStringEq).equals(a.quizAnswers, b.quizAnswers) && diff --git a/tests/types/nullable-string-eq.test.ts b/tests/types/nullable-string-eq.test.ts new file mode 100644 index 00000000..cb428454 --- /dev/null +++ b/tests/types/nullable-string-eq.test.ts @@ -0,0 +1,16 @@ +import {NullableStringEq} from '../../src/types/nullable-string'; + +describe('NullableStringEq', () => { + it('null and null', () => { + expect(NullableStringEq.equals(null, null)); + }); + it('Null and string', () => { + expect(!NullableStringEq.equals(null, 'null')); + }); + it('string and different string', () => { + expect(!NullableStringEq.equals('beans', 'null')); + }); + it('matching strings', () => { + expect(NullableStringEq.equals('beans', 'beans')); + }); +}); diff --git a/tests/types/qz-event.test.ts b/tests/types/qz-event.test.ts new file mode 100644 index 00000000..9fa2bfc5 --- /dev/null +++ b/tests/types/qz-event.test.ts @@ -0,0 +1,72 @@ +import {v4} from 'uuid'; +import {QzEvent, QzEventDuplicate} from '../../src/types/qz-event'; +import {UUID} from 'io-ts-types'; +import {DomainEvent} from '../../src/types'; +import {getRightOrFail} from '../helpers'; + +const EVENT_1: QzEvent = { + equipmentId: v4() as UUID, + trainingSheetId: 'sheetid123', + memberNumberProvided: 1233, + emailProvided: 'finn.flatcoat@dogs.com', + score: 2, + id: v4() as UUID, + recordedAt: new Date(2024, 6, 6, 13, 42, 0), + maxScore: 10, + percentage: 20, + fullMarks: false, + timestampEpochS: 1520652364, + quizAnswers: { + q1: 'a1', + q2: 'a2', + q3: 'a3', + }, + type: 'EquipmentTrainingQuizResult', + actor: { + tag: 'system', + }, +}; + +const EVENT_2: QzEvent = { + equipmentId: v4() as UUID, + trainingSheetId: 'sheetid123', + memberNumberProvided: 1244, + emailProvided: 'beans@bob.com', + score: 7, + id: v4() as UUID, + recordedAt: new Date(2024, 5, 8, 13, 42, 0), + maxScore: 10, + percentage: 70, + fullMarks: false, + timestampEpochS: 1520652999, + quizAnswers: { + q1: 'a1', + q2: 'a7', + q3: 'a9', + }, + type: 'EquipmentTrainingQuizResult', + actor: { + tag: 'system', + }, +}; + +describe('QzEvent', () => { + it('Check simple duplicate', () => { + expect(QzEventDuplicate.equals(EVENT_1, EVENT_1)); + }); + it('Check different object simple duplicate', () => { + expect( + QzEventDuplicate.equals( + getRightOrFail( + DomainEvent.decode(DomainEvent.encode(EVENT_1)) + ) as QzEvent, + getRightOrFail( + DomainEvent.decode(DomainEvent.encode(EVENT_1)) + ) as QzEvent + ) + ); + }); + it('Check distinct', () => { + expect(!QzEventDuplicate.equals(EVENT_1, EVENT_2)); + }); +}); From 35be7e7d9928fa5b0ab047df05d36f583d20d798 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Thu, 11 Jul 2024 00:21:33 +0100 Subject: [PATCH 18/38] Fix tests being wip --- src/queries/equipment/construct-view-model.ts | 7 +++---- tests/types/nullable-string-eq.test.ts | 8 ++++---- tests/types/qz-event.test.ts | 6 +++--- 3 files changed, 10 insertions(+), 11 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index 2b29e8b9..c5d8ebea 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -13,11 +13,10 @@ import { QuizResultViewModel, ViewModel, } from './view-model'; -import {MemberDetails, User} from '../../types'; +import {MemberDetails, MultipleMemberDetails, User} from '../../types'; import {StatusCodes} from 'http-status-codes'; import {DomainEvent, EventOfType} from '../../types/domain-event'; import {Equipment} from '../../read-models/equipment/get'; -import {AllMemberDetails} from '../../read-models/members/get-all'; import {Logger} from 'pino'; import {getMembersTrainedOn} from '../../read-models/equipment/get-trained-on'; import {DateTime} from 'luxon'; @@ -34,7 +33,7 @@ const getEquipment = ( ) ); -const indexMembersByEmail = (byId: AllMemberDetails) => { +const indexMembersByEmail = (byId: MultipleMemberDetails) => { return pipe( Object.values(byId), RA.reduce( @@ -108,7 +107,7 @@ const updateQuizResults = ( const reduceToLatestQuizResultByMember = ( logger: Logger, - members: AllMemberDetails, + members: MultipleMemberDetails, membersByEmail: Record, quizResults: ReturnType ) => { diff --git a/tests/types/nullable-string-eq.test.ts b/tests/types/nullable-string-eq.test.ts index cb428454..d8ab4b7a 100644 --- a/tests/types/nullable-string-eq.test.ts +++ b/tests/types/nullable-string-eq.test.ts @@ -2,15 +2,15 @@ import {NullableStringEq} from '../../src/types/nullable-string'; describe('NullableStringEq', () => { it('null and null', () => { - expect(NullableStringEq.equals(null, null)); + expect(NullableStringEq.equals(null, null)).toBeTruthy(); }); it('Null and string', () => { - expect(!NullableStringEq.equals(null, 'null')); + expect(NullableStringEq.equals(null, 'null')).toBeFalsy(); }); it('string and different string', () => { - expect(!NullableStringEq.equals('beans', 'null')); + expect(NullableStringEq.equals('beans', 'null')).toBeFalsy(); }); it('matching strings', () => { - expect(NullableStringEq.equals('beans', 'beans')); + expect(NullableStringEq.equals('beans', 'beans')).toBeTruthy(); }); }); diff --git a/tests/types/qz-event.test.ts b/tests/types/qz-event.test.ts index 9fa2bfc5..07cbb076 100644 --- a/tests/types/qz-event.test.ts +++ b/tests/types/qz-event.test.ts @@ -52,7 +52,7 @@ const EVENT_2: QzEvent = { describe('QzEvent', () => { it('Check simple duplicate', () => { - expect(QzEventDuplicate.equals(EVENT_1, EVENT_1)); + expect(QzEventDuplicate.equals(EVENT_1, EVENT_1)).toBeTruthy(); }); it('Check different object simple duplicate', () => { expect( @@ -64,9 +64,9 @@ describe('QzEvent', () => { DomainEvent.decode(DomainEvent.encode(EVENT_1)) ) as QzEvent ) - ); + ).toBeTruthy(); }); it('Check distinct', () => { - expect(!QzEventDuplicate.equals(EVENT_1, EVENT_2)); + expect(QzEventDuplicate.equals(EVENT_1, EVENT_2)).toBeFalsy(); }); }); From 08be1283cb7f05c86afd8a43e116483320d5ee12 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 09:46:17 +0100 Subject: [PATCH 19/38] Trying to make constructing view model more functional --- src/queries/equipment/construct-view-model.ts | 130 ++++++++++-------- 1 file changed, 72 insertions(+), 58 deletions(-) diff --git a/src/queries/equipment/construct-view-model.ts b/src/queries/equipment/construct-view-model.ts index c5d8ebea..e0417917 100644 --- a/src/queries/equipment/construct-view-model.ts +++ b/src/queries/equipment/construct-view-model.ts @@ -17,7 +17,6 @@ import {MemberDetails, MultipleMemberDetails, User} from '../../types'; import {StatusCodes} from 'http-status-codes'; import {DomainEvent, EventOfType} from '../../types/domain-event'; import {Equipment} from '../../read-models/equipment/get'; -import {Logger} from 'pino'; import {getMembersTrainedOn} from '../../read-models/equipment/get-trained-on'; import {DateTime} from 'luxon'; @@ -47,16 +46,14 @@ const indexMembersByEmail = (byId: MultipleMemberDetails) => { ); }; -const getQuizEvents = ( - events: ReadonlyArray, - equipment: Equipment -) => { - const results: { - [ - index: EventOfType<'EquipmentTrainingQuizResult'>['id'] - ]: EventOfType<'EquipmentTrainingQuizResult'>; - } = {}; - events.forEach(event => { +type QuizResultsMap = { + [ + index: EventOfType<'EquipmentTrainingQuizResult'>['id'] + ]: EventOfType<'EquipmentTrainingQuizResult'>; +}; + +const applyQuizResultUpdate = + (equipment: Equipment) => (results: QuizResultsMap, event: DomainEvent) => { // Requires events to be provided in order. switch (event.type) { case 'EquipmentTrainingQuizResult': @@ -77,9 +74,17 @@ const getQuizEvents = ( default: break; } - }); - return Object.values(results); -}; + return results; + }; + +const getQuizEvents = + (equipment: Equipment) => + ( + events: ReadonlyArray + ): ReadonlyArray> => + pipe(events, RA.reduce({}, applyQuizResultUpdate(equipment)), results => { + return Object.values(results); + }); const updateQuizResults = ( memberQuizResults: Record, @@ -105,54 +110,64 @@ const updateQuizResults = ( existing.otherAttempts = existing.otherAttempts.concat([quizResult.id]); }; -const reduceToLatestQuizResultByMember = ( - logger: Logger, +const quizResultsMatch = ( members: MultipleMemberDetails, membersByEmail: Record, - quizResults: ReturnType -) => { - const memberQuizResults: Record = {}; - const unknownMemberQuizResults: QuizResultUnknownMemberViewModel[] = []; - for (const quizResult of quizResults) { - const memberNumber = O.fromNullable(quizResult.memberNumberProvided); - const email = O.fromNullable(quizResult.emailProvided); - - const needToMatch: O.Option[] = []; - if (O.isSome(memberNumber)) { - needToMatch.push(O.fromNullable(members.get(memberNumber.value))); - } - if (O.isSome(email)) { - needToMatch.push(O.fromNullable(membersByEmail[email.value])); - } + quizResult: EventOfType<'EquipmentTrainingQuizResult'> +): O.Option => { + const memberNumber = O.fromNullable(quizResult.memberNumberProvided); + const email = O.fromNullable(quizResult.emailProvided); - if ( - (needToMatch.length === 1 && O.isSome(needToMatch[0])) || - (needToMatch.length === 2 && - O.isSome(needToMatch[0]) && - needToMatch[0] === needToMatch[1]) - ) { - updateQuizResults(memberQuizResults, needToMatch[0].value, quizResult); - continue; - } - unknownMemberQuizResults.push({ - id: quizResult.id, - score: quizResult.score, - maxScore: quizResult.maxScore, - percentage: quizResult.percentage, - passed: quizResult.fullMarks, - timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), - memberNumberProvided: quizResult.memberNumberProvided, - emailProvided: quizResult.emailProvided, - }); + const needToMatch: O.Option[] = []; + if (O.isSome(memberNumber)) { + needToMatch.push(O.fromNullable(members.get(memberNumber.value))); } - return { - memberQuizResults, - unknownMemberQuizResults, - }; + if (O.isSome(email)) { + needToMatch.push(O.fromNullable(membersByEmail[email.value])); + } + + return (needToMatch.length === 1 && O.isSome(needToMatch[0])) || + (needToMatch.length === 2 && + O.isSome(needToMatch[0]) && + needToMatch[0] === needToMatch[1]) + ? needToMatch[0] + : O.none; }; +const reduceToLatestQuizResultByMember = ( + members: MultipleMemberDetails, + membersByEmail: Record, + quizResults: ReadonlyArray> +) => + pipe( + quizResults, + RA.reduce( + { + memberQuizResults: {} as Record, + unknownMemberQuizResults: [] as QuizResultUnknownMemberViewModel[], + }, + (result, quizResult) => { + const member = quizResultsMatch(members, membersByEmail, quizResult); + if (O.isSome(member)) { + updateQuizResults(result.memberQuizResults, member.value, quizResult); + } else { + result.unknownMemberQuizResults.push({ + id: quizResult.id, + score: quizResult.score, + maxScore: quizResult.maxScore, + percentage: quizResult.percentage, + passed: quizResult.fullMarks, + timestamp: DateTime.fromSeconds(quizResult.timestampEpochS), + memberNumberProvided: quizResult.memberNumberProvided, + emailProvided: quizResult.emailProvided, + }); + } + return result; + } + ) + ); + const getQuizResults = ( - logger: Logger, events: ReadonlyArray, equipment: Equipment ): TE.TaskEither< @@ -169,10 +184,9 @@ const getQuizResults = ( > => { const members = readModels.members.getAllDetails(events); const membersByEmail = indexMembersByEmail(members); - const quizEvents = getQuizEvents(events, equipment); + const quizEvents = getQuizEvents(equipment)(events); const trainedMembers = getMembersTrainedOn(equipment.id)(events); const memberResults = reduceToLatestQuizResultByMember( - logger, members, membersByEmail, quizEvents @@ -230,6 +244,6 @@ export const constructViewModel = isSuperUserOrTrainerOfEquipment(events, equipment, user.memberNumber) ), TE.bindW('trainingQuizResults', ({events, equipment}) => - getQuizResults(deps.logger, events, equipment) + getQuizResults(events, equipment) ) ); From 2b4456f827bfde50b521fe8f17fc84596ab68b33 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 09:59:52 +0100 Subject: [PATCH 20/38] Start writing tests for equipment view model --- .../equipment/construct-view-model.test.ts | 25 +++++++++++++++++++ 1 file changed, 25 insertions(+) create mode 100644 tests/queries/equipment/construct-view-model.test.ts diff --git a/tests/queries/equipment/construct-view-model.test.ts b/tests/queries/equipment/construct-view-model.test.ts new file mode 100644 index 00000000..e0d3d1f7 --- /dev/null +++ b/tests/queries/equipment/construct-view-model.test.ts @@ -0,0 +1,25 @@ +import * as TE from 'fp-ts/TaskEither'; +import {constructViewModel} from '../../../src/queries/equipment/construct-view-model'; +import {UUID} from 'io-ts-types'; +import {v4} from 'uuid'; +import {StatusCodes} from 'http-status-codes'; +import {getLeftOrFail} from '../../helpers.js'; +import { happyPathAdapters } from '../../init-dependencies/happy-path-adapters.helper'; +import {arbitraryUser} from '../../types/user.helper'; +import {Dependencies} from '../../../src/dependencies'; + +describe('construct-view-model', () => { + describe('no equipment events', () => { + const user = arbitraryUser(); + const deps: Dependencies = { + ...happyPathAdapters, + getAllEvents: () => TE.right([]), + }; + it('Produces a failure status', async () => { + const failure = getLeftOrFail( + await constructViewModel(deps, user)(v4() as UUID)() + ); + expect(failure.status).toEqual(StatusCodes.NOT_FOUND); + }); + }); +}); From 5a36428ff50a1f0ae484dc5e3e1b2741317cfefa Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 22:22:58 +0100 Subject: [PATCH 21/38] Fix imports --- tests/queries/equipment/construct-view-model.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/queries/equipment/construct-view-model.test.ts b/tests/queries/equipment/construct-view-model.test.ts index e0d3d1f7..d3f0a003 100644 --- a/tests/queries/equipment/construct-view-model.test.ts +++ b/tests/queries/equipment/construct-view-model.test.ts @@ -3,10 +3,10 @@ import {constructViewModel} from '../../../src/queries/equipment/construct-view- import {UUID} from 'io-ts-types'; import {v4} from 'uuid'; import {StatusCodes} from 'http-status-codes'; -import {getLeftOrFail} from '../../helpers.js'; -import { happyPathAdapters } from '../../init-dependencies/happy-path-adapters.helper'; +import {happyPathAdapters} from '../../init-dependencies/happy-path-adapters.helper'; import {arbitraryUser} from '../../types/user.helper'; import {Dependencies} from '../../../src/dependencies'; +import {getLeftOrFail} from '../../helpers'; describe('construct-view-model', () => { describe('no equipment events', () => { From a0c47b7ded11e0319ee1f4da6b22e950e3f7c954 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 23:45:05 +0100 Subject: [PATCH 22/38] Log attempts to decode magic link --- src/authentication/magic-link.ts | 20 ++++++++++++-------- src/util.ts | 8 ++++++++ 2 files changed, 20 insertions(+), 8 deletions(-) diff --git a/src/authentication/magic-link.ts b/src/authentication/magic-link.ts index eebde744..b17b4e66 100644 --- a/src/authentication/magic-link.ts +++ b/src/authentication/magic-link.ts @@ -7,6 +7,8 @@ import jwt from 'jsonwebtoken'; import {Config} from '../configuration'; import {Dependencies} from '../dependencies'; import {User} from '../types/user'; +import {logPassThru} from '../util'; +import {Logger} from 'pino'; const createMagicLink = (conf: Config) => (user: User) => pipe( @@ -24,19 +26,21 @@ const verifyToken = (token: string, secret: Config['TOKEN_SECRET']) => failure('Could not verify token') ); -const decodeMagicLinkFromQuery = (conf: Config) => (input: unknown) => - pipe( - input, - MagicLinkQuery.decode, - E.chainW(({token}) => verifyToken(token, conf.TOKEN_SECRET)), - E.chainW(User.decode) - ); +const decodeMagicLinkFromQuery = + (logger: Logger, conf: Config) => (input: unknown) => + pipe( + input, + logPassThru(logger, 'Attempting to decode magic link from query'), // Logging is required as a basic form of auth enumeration detection. + MagicLinkQuery.decode, + E.chainW(({token}) => verifyToken(token, conf.TOKEN_SECRET)), + E.chainW(User.decode) + ); const strategy = (deps: Dependencies, conf: Config) => { return new CustomStrategy((req, done) => { pipe( req.query, - decodeMagicLinkFromQuery(conf), + decodeMagicLinkFromQuery(deps.logger, conf), E.match( error => { deps.logger.info( diff --git a/src/util.ts b/src/util.ts index b3600b78..b2dcfc89 100644 --- a/src/util.ts +++ b/src/util.ts @@ -3,6 +3,7 @@ import * as RA from 'fp-ts/ReadonlyArray'; import * as RR from 'fp-ts/ReadonlyRecord'; import {record} from 'fp-ts'; +import {Logger} from 'pino'; // Take the last element in the array for each key generated by fn. // This probably already exists as a function in fp-ts. @@ -30,3 +31,10 @@ export const accumBy = record.map(RA.fromArray), RR.fromRecord ); + +export const logPassThru = + (logger: Logger, msg: string) => + (input: T) => { + logger.info(msg); + return input; + }; From 1106c72346595af4335c0c3519233dee0ecf20ba Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 23:48:14 +0100 Subject: [PATCH 23/38] Fix invalid login magic link display of login redirect --- src/authentication/handlers.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/authentication/handlers.ts b/src/authentication/handlers.ts index 4bb72992..80f48060 100644 --- a/src/authentication/handlers.ts +++ b/src/authentication/handlers.ts @@ -10,6 +10,7 @@ import {logInPage} from './log-in-page'; import {checkYourMailPage} from './check-your-mail'; import {oopsPage} from '../templates'; import {StatusCodes} from 'http-status-codes'; +import { SafeString } from 'handlebars'; export const logIn = (req: Request, res: Response) => { res.status(StatusCodes.OK).send(logInPage); @@ -41,7 +42,9 @@ export const invalidLink = .status(StatusCodes.UNAUTHORIZED) .send( oopsPage( - `The link you have used is (no longer) valid. Go back to the log in` + new SafeString( + `The link you have used is (no longer) valid. Go back to the log in` + ) ) ); }; From d8f66222cc70729191ea5d3c0fc09340d7c01d88 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 23:48:45 +0100 Subject: [PATCH 24/38] Fix lint --- src/authentication/handlers.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/authentication/handlers.ts b/src/authentication/handlers.ts index 80f48060..59eb0767 100644 --- a/src/authentication/handlers.ts +++ b/src/authentication/handlers.ts @@ -10,7 +10,7 @@ import {logInPage} from './log-in-page'; import {checkYourMailPage} from './check-your-mail'; import {oopsPage} from '../templates'; import {StatusCodes} from 'http-status-codes'; -import { SafeString } from 'handlebars'; +import {SafeString} from 'handlebars'; export const logIn = (req: Request, res: Response) => { res.status(StatusCodes.OK).send(logInPage); From a25dddb7738ab9679145594e8f1a1a2233b48e68 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Sun, 14 Jul 2024 23:58:39 +0100 Subject: [PATCH 25/38] Auto redirect if already logged in on login page --- src/authentication/auth-routes.ts | 17 ++++++++++------- src/authentication/handlers.ts | 16 ++++++++++++++-- src/routes.ts | 2 +- 3 files changed, 25 insertions(+), 10 deletions(-) diff --git a/src/authentication/auth-routes.ts b/src/authentication/auth-routes.ts index 2e11956a..f38101b6 100644 --- a/src/authentication/auth-routes.ts +++ b/src/authentication/auth-routes.ts @@ -1,13 +1,16 @@ +import {Dependencies} from '../dependencies'; import {Route, get, post} from '../types/route'; import {auth, callback, invalidLink, logIn, logOut} from './handlers'; export const logInPath = '/log-in'; const invalidLinkPath = '/auth/invalid-magic-link'; -export const authRoutes: ReadonlyArray = [ - get(logInPath, logIn), - get('/log-out', logOut), - post('/auth', auth), - get('/auth/callback', callback(invalidLinkPath)), - get(invalidLinkPath, invalidLink(logInPath)), -]; +export const authRoutes = (deps: Dependencies): ReadonlyArray => { + return [ + get(logInPath, logIn(deps)), + get('/log-out', logOut), + post('/auth', auth), + get('/auth/callback', callback(invalidLinkPath)), + get(invalidLinkPath, invalidLink(logInPath)), + ]; +}; diff --git a/src/authentication/handlers.ts b/src/authentication/handlers.ts index 59eb0767..5a60d719 100644 --- a/src/authentication/handlers.ts +++ b/src/authentication/handlers.ts @@ -3,6 +3,7 @@ import {Request, Response} from 'express'; import {pipe} from 'fp-ts/lib/function'; import {parseEmailAddressFromBody} from './parse-email-address-from-body'; import * as E from 'fp-ts/Either'; +import * as O from 'fp-ts/Option'; import {publish} from 'pubsub-js'; import passport from 'passport'; import {magicLink} from './magic-link'; @@ -11,9 +12,20 @@ import {checkYourMailPage} from './check-your-mail'; import {oopsPage} from '../templates'; import {StatusCodes} from 'http-status-codes'; import {SafeString} from 'handlebars'; +import {getUserFromSession} from './get-user-from-session'; +import {Dependencies} from '../dependencies'; -export const logIn = (req: Request, res: Response) => { - res.status(StatusCodes.OK).send(logInPage); +export const logIn = (deps: Dependencies) => (req: Request, res: Response) => { + pipe( + req.session, + getUserFromSession(deps), + O.match( + () => { + res.status(StatusCodes.OK).send(logInPage); + }, + _user => res.redirect('/') + ) + ); }; export const logOut = (req: Request, res: Response) => { diff --git a/src/routes.ts b/src/routes.ts index 955abfe3..5a6951d5 100644 --- a/src/routes.ts +++ b/src/routes.ts @@ -59,6 +59,6 @@ export const initRoutes = ( ), email('owner-agreement-invite', sendEmailCommands.ownerAgreementInvite), get('/ping', ping), - ...authRoutes, + ...authRoutes(deps), ]; }; From cebe35fa329871a62915e1ddf071c03883854d9c Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 15 Jul 2024 00:11:19 +0100 Subject: [PATCH 26/38] Remove log in / out dependency in navbar which requires logged in user --- src/authentication/check-your-mail.ts | 8 +--- src/authentication/log-in-page.ts | 10 ++--- src/queries/member/render.ts | 6 +-- src/queries/super-users/render.ts | 2 +- src/templates/navbar.ts | 6 +-- src/templates/page-template.ts | 61 ++++++++++++++++----------- 6 files changed, 45 insertions(+), 48 deletions(-) diff --git a/src/authentication/check-your-mail.ts b/src/authentication/check-your-mail.ts index 0f9eabe3..720a55d1 100644 --- a/src/authentication/check-your-mail.ts +++ b/src/authentication/check-your-mail.ts @@ -1,6 +1,5 @@ -import * as O from 'fp-ts/Option'; -import {pageTemplate} from '../templates'; import Handlebars from 'handlebars'; +import {isolatedPageTemplate} from '../templates/page-template'; const CHECK_YOUR_MAIL_TEMPLATE = Handlebars.compile( ` @@ -14,10 +13,7 @@ const CHECK_YOUR_MAIL_TEMPLATE = Handlebars.compile( ); export const checkYourMailPage = (submittedEmailAddress: string) => - pageTemplate( - 'Check your mail', - O.none - )( + isolatedPageTemplate('Check your mail')( new Handlebars.SafeString( CHECK_YOUR_MAIL_TEMPLATE({ submittedEmailAddress, diff --git a/src/authentication/log-in-page.ts b/src/authentication/log-in-page.ts index d2e9495a..a514fab1 100644 --- a/src/authentication/log-in-page.ts +++ b/src/authentication/log-in-page.ts @@ -1,6 +1,5 @@ -import * as O from 'fp-ts/Option'; -import {pageTemplate} from '../templates'; import Handlebars, {SafeString} from 'handlebars'; +import {isolatedPageTemplate} from '../templates/page-template'; const LOGIN_PAGE_TEMPLATE = Handlebars.compile( ` @@ -14,7 +13,6 @@ const LOGIN_PAGE_TEMPLATE = Handlebars.compile( ` ); -export const logInPage = pageTemplate( - 'MakeSpace Members App', - O.none -)(new SafeString(LOGIN_PAGE_TEMPLATE({}))); +export const logInPage = isolatedPageTemplate('MakeSpace Members App')( + new SafeString(LOGIN_PAGE_TEMPLATE({})) +); diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index d7228c47..bdff4537 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -2,7 +2,6 @@ import Handlebars, {SafeString} from 'handlebars'; import {pageTemplate} from '../../templates'; import {User} from '../../types'; import {ViewModel} from './view-model'; -import * as O from 'fp-ts/Option'; const RENDER_TEMPLATE = Handlebars.compile( ` @@ -56,7 +55,4 @@ const RENDER_TEMPLATE = Handlebars.compile( ); export const render = (viewModel: ViewModel, user: User) => - pageTemplate( - 'Member', - O.some(user) - )(new SafeString(RENDER_TEMPLATE(viewModel))); + pageTemplate('Member', user)(new SafeString(RENDER_TEMPLATE(viewModel))); diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index a3c8e00b..21f64028 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -41,5 +41,5 @@ const SUPER_USERS_TEMPLATE = Handlebars.compile(` export const render = (viewModel: ViewModel) => pageTemplate( 'Super Users', - O.some(viewModel.user) + viewModel.user )(new SafeString(SUPER_USERS_TEMPLATE(viewModel))); diff --git a/src/templates/navbar.ts b/src/templates/navbar.ts index a5dd922a..7bc3725b 100644 --- a/src/templates/navbar.ts +++ b/src/templates/navbar.ts @@ -16,11 +16,7 @@ export const registerNavBar = () => { Members Equipment Areas - {{#if loggedIn}} - Log in - {{else}} - Log out - {{/if}} + Log out ` ); diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index 40b54865..237c4e99 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -1,4 +1,3 @@ -import * as O from 'fp-ts/Option'; import {User, HttpResponse} from '../types'; import Handlebars, {SafeString} from 'handlebars'; import {registerHead} from './head'; @@ -22,32 +21,44 @@ registerFilterListHelper(); registerMemberInput(); const PAGE_TEMPLATE = Handlebars.compile(` - - - {{> head }} -
- {{#if navbarRequired}} - {{> navbar }} - {{/if}} -
- - {{body}} - {{> gridjs }} - - - `); + + + {{> head }} +
+ {{> navbar }} +
+ + {{body}} + {{> gridjs }} + + +`); -export const pageTemplate = - (title: string, user: O.Option) => (body: SafeString) => - PAGE_TEMPLATE({ - title, - loggedIn: O.isSome(user), - body: body, +// For pages not part of the normal flow. +const ISOLATED_PAGE_TEMPLATE = Handlebars.compile(` + + + {{> head }} + + {{body}} + {{> gridjs }} + + +`); - // For simplicity the navbar is always present if the user is logged in but - // we may want to separate these conditions. - navbarRequired: O.isSome(user), - }); +export const pageTemplate = (title: string, user: User) => (body: SafeString) => + PAGE_TEMPLATE({ + title, + loggedIn: user, + body, + navbarRequired: true, + }); + +export const isolatedPageTemplate = (title: string) => (body: SafeString) => + ISOLATED_PAGE_TEMPLATE({ + title, + body, + }); export const templatePage: (r: HttpResponse) => HttpResponse = HttpResponse.match({ From d1879448e15db85789e9671edc8b0a8ad34b6ace Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 15 Jul 2024 00:16:05 +0100 Subject: [PATCH 27/38] Update usages of pageTemplate --- src/commands/area/add-owner-form.ts | 2 +- src/commands/area/create-form.ts | 2 +- src/commands/equipment/add-form.ts | 2 +- .../equipment/register-training-sheet-form.ts | 2 +- .../member-numbers/link-number-to-email-form.ts | 2 +- src/commands/members/edit-name-form.ts | 2 +- src/commands/members/edit-pronouns-form.ts | 2 +- src/commands/members/sign-owner-agreement-form.ts | 2 +- src/commands/super-user/declare-form.ts | 2 +- src/commands/super-user/revoke-form.ts | 2 +- src/commands/trainers/add-trainer-form.ts | 2 +- src/commands/trainers/mark-member-trained-form.ts | 2 +- src/http/email-handler.ts | 11 +++++------ src/queries/all-equipment/render.ts | 2 +- src/queries/area/render.ts | 2 +- src/queries/areas/render.ts | 2 +- src/queries/equipment/render.ts | 2 +- src/queries/failed-imports/render.ts | 2 +- src/queries/landing/render.ts | 2 +- src/queries/log/render.ts | 2 +- src/queries/members/render.ts | 2 +- 21 files changed, 25 insertions(+), 26 deletions(-) diff --git a/src/commands/area/add-owner-form.ts b/src/commands/area/add-owner-form.ts index ef8d3084..eb343a41 100644 --- a/src/commands/area/add-owner-form.ts +++ b/src/commands/area/add-owner-form.ts @@ -101,7 +101,7 @@ const ADD_OWNER_FORM_TEMPLATE = Handlebars.compile( const renderForm = (viewModel: ViewModel) => pageTemplate( 'Add Owner', - O.some(viewModel.user) + viewModel.user )(new SafeString(ADD_OWNER_FORM_TEMPLATE(viewModel))); const paramsCodec = t.strict({ diff --git a/src/commands/area/create-form.ts b/src/commands/area/create-form.ts index b9fd9f0e..dbc57c18 100644 --- a/src/commands/area/create-form.ts +++ b/src/commands/area/create-form.ts @@ -25,7 +25,7 @@ const CREATE_FORM_TEMPLATE = Handlebars.compile( const renderForm = (viewModel: ViewModel) => pageTemplate( 'Create Area', - O.some(viewModel.user) + viewModel.user )( new SafeString( CREATE_FORM_TEMPLATE({ diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index 8d67c023..f150cf24 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -33,7 +33,7 @@ const RENDER_ADD_EQUIPMENT_FORM_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Create Equipment', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_ADD_EQUIPMENT_FORM_TEMPLATE(viewModel))); const getAreaId = (input: unknown) => diff --git a/src/commands/equipment/register-training-sheet-form.ts b/src/commands/equipment/register-training-sheet-form.ts index e3012b86..d3f2c1ea 100644 --- a/src/commands/equipment/register-training-sheet-form.ts +++ b/src/commands/equipment/register-training-sheet-form.ts @@ -33,7 +33,7 @@ const RENDER_REGISTER_TRAINING_SHEET_TEMPLATE = Handlebars.compile( const renderForm = (viewModel: ViewModel) => pageTemplate( 'Register training sheet', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_REGISTER_TRAINING_SHEET_TEMPLATE(viewModel))); const constructForm: Form['constructForm'] = diff --git a/src/commands/member-numbers/link-number-to-email-form.ts b/src/commands/member-numbers/link-number-to-email-form.ts index 3a31dbd8..2726333b 100644 --- a/src/commands/member-numbers/link-number-to-email-form.ts +++ b/src/commands/member-numbers/link-number-to-email-form.ts @@ -26,7 +26,7 @@ const RENDER_LINK_NUMBER_TO_EMAIL_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Link a member number to an e-mail address', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_LINK_NUMBER_TO_EMAIL_TEMPLATE(viewModel))); const constructForm: Form['constructForm'] = diff --git a/src/commands/members/edit-name-form.ts b/src/commands/members/edit-name-form.ts index a0ab5587..e1ec185c 100644 --- a/src/commands/members/edit-name-form.ts +++ b/src/commands/members/edit-name-form.ts @@ -33,7 +33,7 @@ const RENDER_EDIT_NAME_FORM_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Edit name', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_EDIT_NAME_FORM_TEMPLATE(viewModel))); const paramsCodec = t.strict({ diff --git a/src/commands/members/edit-pronouns-form.ts b/src/commands/members/edit-pronouns-form.ts index 7bc56ed5..f676872c 100644 --- a/src/commands/members/edit-pronouns-form.ts +++ b/src/commands/members/edit-pronouns-form.ts @@ -33,7 +33,7 @@ const RENDER_EDIT_PRONOUNS_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Edit pronouns', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_EDIT_PRONOUNS_TEMPLATE(viewModel))); const paramsCodec = t.strict({ diff --git a/src/commands/members/sign-owner-agreement-form.ts b/src/commands/members/sign-owner-agreement-form.ts index c5028abc..04534610 100644 --- a/src/commands/members/sign-owner-agreement-form.ts +++ b/src/commands/members/sign-owner-agreement-form.ts @@ -31,7 +31,7 @@ const SIGN_OWNER_AGREEMENT_FORM_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Sign Owner Agreement', - O.some(viewModel.user) + viewModel.user )(new SafeString(SIGN_OWNER_AGREEMENT_FORM_TEMPLATE(viewModel))); const constructForm: Form['constructForm'] = diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index 3288bb81..0e0c4da4 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -28,7 +28,7 @@ const RENDER_DECLARE_SUPER_USER_TEMPLATE = Handlebars.compile( const renderForm = (viewModel: ViewModel) => pageTemplate( 'Declare super user', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_DECLARE_SUPER_USER_TEMPLATE(viewModel))); const constructForm: Form['constructForm'] = diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index b4d90260..da0c40fc 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -39,7 +39,7 @@ const RENDER_REVOKE_SUPER_USER_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Revoke super user', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_REVOKE_SUPER_USER_TEMPLATE(viewModel))); const paramsCodec = t.strict({ diff --git a/src/commands/trainers/add-trainer-form.ts b/src/commands/trainers/add-trainer-form.ts index eb52b7da..5f1ac0b8 100644 --- a/src/commands/trainers/add-trainer-form.ts +++ b/src/commands/trainers/add-trainer-form.ts @@ -56,7 +56,7 @@ const RENDER_ADD_TRAINER_FORM_TEMPLATE = Handlebars.compile(` const renderForm = (viewModel: ViewModel) => pageTemplate( 'Add Trainer', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_ADD_TRAINER_FORM_TEMPLATE(viewModel))); const getEquipmentId = (input: unknown) => diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index 11eafa13..7a86cf78 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -38,7 +38,7 @@ const RENDER_MARK_MEMBER_TRAINED_TEMPLATE = Handlebars.compile( const renderForm = (viewModel: ViewModel) => pageTemplate( 'Member Training Complete', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_MARK_MEMBER_TRAINED_TEMPLATE(viewModel))); const constructForm: Form['constructForm'] = diff --git a/src/http/email-handler.ts b/src/http/email-handler.ts index 60b36981..e817469f 100644 --- a/src/http/email-handler.ts +++ b/src/http/email-handler.ts @@ -3,7 +3,7 @@ import {Dependencies} from '../dependencies'; import {flow, pipe} from 'fp-ts/lib/function'; import {sequenceS} from 'fp-ts/lib/Apply'; import {StatusCodes} from 'http-status-codes'; -import {oopsPage, pageTemplate} from '../templates'; +import {oopsPage} from '../templates'; import {failureWithStatus} from '../types/failure-with-status'; import * as TE from 'fp-ts/TaskEither'; import {getUserFromSession} from '../authentication'; @@ -13,8 +13,8 @@ import * as E from 'fp-ts/Either'; import {formatValidationErrors} from 'io-ts-reporters'; import {SendEmail} from '../commands'; import {Config} from '../configuration'; -import * as O from 'fp-ts/Option'; import Handlebars, {SafeString} from 'handlebars'; +import {isolatedPageTemplate} from '../templates/page-template'; const getActorFrom = (session: unknown, deps: Dependencies) => pipe( @@ -81,10 +81,9 @@ const emailPost = res .status(200) .send( - pageTemplate( - 'Email sent', - O.none - )(new SafeString(EMAIL_SENT_TEMPLATE({}))) + isolatedPageTemplate('Email sent')( + new SafeString(EMAIL_SENT_TEMPLATE({})) + ) ); } ) diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index 62186517..3890ae46 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -48,5 +48,5 @@ const RENDER_ALL_EQUIPMENT_TEMPLATE = Handlebars.compile( export const render = (viewModel: ViewModel) => pageTemplate( 'Equipment', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_ALL_EQUIPMENT_TEMPLATE(viewModel))); diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index 11d59bba..993b7a26 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -56,5 +56,5 @@ const RENDER_AREA_TEMPLATE = Handlebars.compile(` export const render = (viewModel: ViewModel) => pageTemplate( viewModel.area.name, - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_AREA_TEMPLATE(viewModel))); diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 60ad2429..11f85c39 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -50,5 +50,5 @@ const RENDER_AREAS_TEMPLATE = Handlebars.compile(` export const render = (viewModel: ViewModel) => pageTemplate( 'Areas', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_AREAS_TEMPLATE(viewModel))); diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index a8519463..e68f0ca2 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -213,5 +213,5 @@ const RENDER_EQUIPMENT_TEMPLATE = Handlebars.compile( export const render = (viewModel: ViewModel) => pageTemplate( viewModel.equipment.name, - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_EQUIPMENT_TEMPLATE(viewModel))); diff --git a/src/queries/failed-imports/render.ts b/src/queries/failed-imports/render.ts index a62923d3..66464ab2 100644 --- a/src/queries/failed-imports/render.ts +++ b/src/queries/failed-imports/render.ts @@ -26,5 +26,5 @@ const RENDER_FAILED_IMPORTS_TEMPLATE = Handlebars.compile(` export const render = (viewModel: ViewModel) => pageTemplate( 'Failed member imports', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_FAILED_IMPORTS_TEMPLATE(viewModel))); diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index 970d79f3..0f2bf0fd 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -56,5 +56,5 @@ const LANDING_PAGE_TEMPLATE = Handlebars.compile(` export const render = (viewModel: ViewModel) => pageTemplate( 'Dashboard', - O.some(viewModel.user) + viewModel.user )(new SafeString(LANDING_PAGE_TEMPLATE(viewModel))); diff --git a/src/queries/log/render.ts b/src/queries/log/render.ts index 711d0038..86938718 100644 --- a/src/queries/log/render.ts +++ b/src/queries/log/render.ts @@ -44,5 +44,5 @@ const RENDER_LOG_TEMPLATE = Handlebars.compile(` export const render = (viewModel: ViewModel) => pageTemplate( 'Event Log', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_LOG_TEMPLATE(viewModel))); diff --git a/src/queries/members/render.ts b/src/queries/members/render.ts index fc570f99..1d5728ad 100644 --- a/src/queries/members/render.ts +++ b/src/queries/members/render.ts @@ -51,5 +51,5 @@ const RENDER_MEMBERS_TEMPLATE = Handlebars.compile( export const render = (viewModel: ViewModel) => pageTemplate( 'Members', - O.some(viewModel.user) + viewModel.user )(new SafeString(RENDER_MEMBERS_TEMPLATE(viewModel))); From c3764b26d9910014823dffd02aaccb3c713c9283 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 15 Jul 2024 00:30:34 +0100 Subject: [PATCH 28/38] Start on logged in user square to display the thumbnail avatar of the logged in user --- src/logged-in-member-details.ts | 21 +++++++++++++++++++++ src/queries/member/construct-view-model.ts | 2 ++ src/queries/member/render.ts | 8 +++++--- src/queries/member/view-model.ts | 1 + src/templates/logged-in-user-square.ts | 10 ++++++++++ src/templates/navbar.ts | 1 + src/templates/page-template.ts | 17 +++++++++-------- 7 files changed, 49 insertions(+), 11 deletions(-) create mode 100644 src/logged-in-member-details.ts create mode 100644 src/templates/logged-in-user-square.ts diff --git a/src/logged-in-member-details.ts b/src/logged-in-member-details.ts new file mode 100644 index 00000000..2d54af97 --- /dev/null +++ b/src/logged-in-member-details.ts @@ -0,0 +1,21 @@ +import {readModels} from './read-models'; +import {DomainEvent, MemberDetails, User} from './types'; +import * as E from 'fp-ts/Either'; +import { + failureWithStatus, + FailureWithStatus, +} from './types/failure-with-status'; +import {pipe} from 'fp-ts/lib/function'; +import {StatusCodes} from 'http-status-codes'; + +export const userMemberDetails = ( + events: ReadonlyArray, + user: User +): E.Either => + pipe( + events, + readModels.members.getDetails(user.memberNumber), + E.fromOption(() => + failureWithStatus('Unknown logged in member', StatusCodes.NOT_FOUND)() + ) + ); diff --git a/src/queries/member/construct-view-model.ts b/src/queries/member/construct-view-model.ts index a19cdf09..090d185a 100644 --- a/src/queries/member/construct-view-model.ts +++ b/src/queries/member/construct-view-model.ts @@ -11,6 +11,7 @@ import {ViewModel} from './view-model'; import {StatusCodes} from 'http-status-codes'; import {sequenceS} from 'fp-ts/lib/Apply'; import {readModels} from '../../read-models'; +import {loggedInMemberDetails} from '../../logged-in-member-details'; export const constructViewModel = (deps: Dependencies, user: User) => @@ -27,6 +28,7 @@ export const constructViewModel = failureWithStatus('No such member', StatusCodes.NOT_FOUND)() ) ), + loggedInMember: loggedInMemberDetails(events, user), // Potential optimisation here when a member is checking their own page we don't need to get details twice. })), TE.chainEitherK(sequenceS(E.Apply)) ); diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index bdff4537..9a3d20c3 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -1,6 +1,5 @@ import Handlebars, {SafeString} from 'handlebars'; import {pageTemplate} from '../../templates'; -import {User} from '../../types'; import {ViewModel} from './view-model'; const RENDER_TEMPLATE = Handlebars.compile( @@ -54,5 +53,8 @@ const RENDER_TEMPLATE = Handlebars.compile( ` ); -export const render = (viewModel: ViewModel, user: User) => - pageTemplate('Member', user)(new SafeString(RENDER_TEMPLATE(viewModel))); +export const render = (viewModel: ViewModel) => + pageTemplate( + 'Member', + viewModel.loggedInMember + )(new SafeString(RENDER_TEMPLATE(viewModel))); diff --git a/src/queries/member/view-model.ts b/src/queries/member/view-model.ts index 28032007..1bb93733 100644 --- a/src/queries/member/view-model.ts +++ b/src/queries/member/view-model.ts @@ -2,5 +2,6 @@ import {MemberDetails} from '../../types'; export type ViewModel = { member: Readonly; + loggedInMember: Readonly; isSelf: boolean; }; diff --git a/src/templates/logged-in-user-square.ts b/src/templates/logged-in-user-square.ts new file mode 100644 index 00000000..22919ea1 --- /dev/null +++ b/src/templates/logged-in-user-square.ts @@ -0,0 +1,10 @@ +export const registerLoggedInUserSquare = () => { + Handlebars.registerPartial( + 'loggedInUserSquare', + ` + + {{avatar_thumbnail member=loggedInMember}} + + ` + ); +}; diff --git a/src/templates/navbar.ts b/src/templates/navbar.ts index 7bc3725b..f308562c 100644 --- a/src/templates/navbar.ts +++ b/src/templates/navbar.ts @@ -17,6 +17,7 @@ export const registerNavBar = () => { Equipment Areas Log out + {{loggedInUserSquare loggedInMember}} ` ); diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index 237c4e99..49b9e65b 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -1,4 +1,4 @@ -import {User, HttpResponse} from '../types'; +import {HttpResponse, MemberDetails} from '../types'; import Handlebars, {SafeString} from 'handlebars'; import {registerHead} from './head'; import {registerNavBar} from './navbar'; @@ -46,13 +46,14 @@ const ISOLATED_PAGE_TEMPLATE = Handlebars.compile(` `); -export const pageTemplate = (title: string, user: User) => (body: SafeString) => - PAGE_TEMPLATE({ - title, - loggedIn: user, - body, - navbarRequired: true, - }); +export const pageTemplate = + (title: string, loggedInMember: MemberDetails) => (body: SafeString) => + PAGE_TEMPLATE({ + title, + loggedInMember, + body, + navbarRequired: true, + }); export const isolatedPageTemplate = (title: string) => (body: SafeString) => ISOLATED_PAGE_TEMPLATE({ From 50ad55ec3f36db7f3f4f5f6241a6633aacdde1b8 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 15 Jul 2024 22:27:21 +0100 Subject: [PATCH 29/38] Missing alt text member number for avatar profile --- src/templates/avatar.ts | 48 ++++++++++++++++++++++------------------- 1 file changed, 26 insertions(+), 22 deletions(-) diff --git a/src/templates/avatar.ts b/src/templates/avatar.ts index 9a22dce8..14df1b06 100644 --- a/src/templates/avatar.ts +++ b/src/templates/avatar.ts @@ -1,5 +1,4 @@ import {createHash} from 'crypto'; -import {MemberDetails} from '../types'; import Handlebars from 'handlebars'; function getGravatarUrl(email: string, size: number = 160) { @@ -33,25 +32,30 @@ const AVATAR_PROFILE_TEMPLATE = Handlebars.compile( ); export const registerAvatarHelpers = () => { - Handlebars.registerHelper('avatar_thumbnail', (member: MemberDetails) => { - const email = member.email; - return new Handlebars.SafeString( - AVATAR_THUMBNAIL_TEMPLATE({ - url1x: getGravatarUrl(email, 40), - url2x: getGravatarUrl(email, 80), - url4x: getGravatarUrl(email, 160), - memberNumber: member.number, - }) - ); - }); - Handlebars.registerHelper('avatar_large', (member: MemberDetails) => { - const email = member.email; - return new Handlebars.SafeString( - AVATAR_PROFILE_TEMPLATE({ - url1x: getGravatarUrl(email, 320), - url2x: getGravatarUrl(email, 640), - url4x: getGravatarUrl(email, 1280), - }) - ); - }); + Handlebars.registerHelper( + 'avatar_thumbnail', + (email: string, memberNumber: number) => { + return new Handlebars.SafeString( + AVATAR_THUMBNAIL_TEMPLATE({ + url1x: getGravatarUrl(email, 40), + url2x: getGravatarUrl(email, 80), + url4x: getGravatarUrl(email, 160), + memberNumber, + }) + ); + } + ); + Handlebars.registerHelper( + 'avatar_large', + (email: string, memberNumber: number) => { + return new Handlebars.SafeString( + AVATAR_PROFILE_TEMPLATE({ + url1x: getGravatarUrl(email, 320), + url2x: getGravatarUrl(email, 640), + url4x: getGravatarUrl(email, 1280), + memberNumber, + }) + ); + } + ); }; From 32f6a2f2bb40e4a08e5ddbb4424fef8bb04362dc Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 15 Jul 2024 22:31:47 +0100 Subject: [PATCH 30/38] Use emailAddress and memberNumber for memberdetails to match that used by User --- src/logged-in-member-details.ts | 21 --------------------- src/queries/member/render.ts | 4 ++-- src/queries/members/render.ts | 2 +- src/templates/member-input.ts | 2 +- src/templates/page-template.ts | 4 ++-- src/types/member.ts | 4 ++-- 6 files changed, 8 insertions(+), 29 deletions(-) delete mode 100644 src/logged-in-member-details.ts diff --git a/src/logged-in-member-details.ts b/src/logged-in-member-details.ts deleted file mode 100644 index 2d54af97..00000000 --- a/src/logged-in-member-details.ts +++ /dev/null @@ -1,21 +0,0 @@ -import {readModels} from './read-models'; -import {DomainEvent, MemberDetails, User} from './types'; -import * as E from 'fp-ts/Either'; -import { - failureWithStatus, - FailureWithStatus, -} from './types/failure-with-status'; -import {pipe} from 'fp-ts/lib/function'; -import {StatusCodes} from 'http-status-codes'; - -export const userMemberDetails = ( - events: ReadonlyArray, - user: User -): E.Either => - pipe( - events, - readModels.members.getDetails(user.memberNumber), - E.fromOption(() => - failureWithStatus('Unknown logged in member', StatusCodes.NOT_FOUND)() - ) - ); diff --git a/src/queries/member/render.ts b/src/queries/member/render.ts index 9a3d20c3..8381bc9f 100644 --- a/src/queries/member/render.ts +++ b/src/queries/member/render.ts @@ -7,7 +7,7 @@ const RENDER_TEMPLATE = Handlebars.compile( {{#if isSelf}}

This is your profile!

{{/if}} -
{{avatar_large member}}
+
{{avatar_large member.emailAddress member.memberNumber}}
{{#each members}} - + diff --git a/src/templates/member-input.ts b/src/templates/member-input.ts index da94f47a..326f128a 100644 --- a/src/templates/member-input.ts +++ b/src/templates/member-input.ts @@ -10,7 +10,7 @@ export const registerMemberInput = () => {
{{#each members}} - - + +
Details @@ -42,7 +42,7 @@ const RENDER_TEMPLATE = Handlebars.compile(
Avatar - {{avatar_thumbnail member}} + {{avatar_thumbnail member.emailAddress member.memberNumber}} {{#if isSelf}} Edit via Gravatar {{/if}} diff --git a/src/queries/members/render.ts b/src/queries/members/render.ts index 1d5728ad..8c5cc69f 100644 --- a/src/queries/members/render.ts +++ b/src/queries/members/render.ts @@ -19,7 +19,7 @@ Handlebars.registerPartial(
{{avatar_thumbnail this}}{{avatar_thumbnail this.emailAddress this.memberNumber}} {{member_number this.number}}
{{this.email}}{{this.number}}{{this.emailAddress}}{{this.memberNumber}}
- + Date: Mon, 15 Jul 2024 22:51:06 +0100 Subject: [PATCH 35/38] Fix unused imports / failed tests --- src/commands/area/create-form.ts | 1 - src/commands/equipment/add-form.ts | 1 - .../equipment/register-training-sheet-form.ts | 1 - .../member-numbers/link-number-to-email-form.ts | 1 - src/commands/members/edit-name-form.ts | 1 - src/commands/members/edit-pronouns-form.ts | 1 - .../members/sign-owner-agreement-form.ts | 1 - src/commands/super-user/declare-form.ts | 1 - src/commands/super-user/revoke-form.ts | 1 - .../trainers/mark-member-trained-form.ts | 1 - src/queries/all-equipment/render.ts | 2 -- src/queries/area/render.ts | 1 - src/queries/areas/render.ts | 1 - src/queries/equipment/render.ts | 1 - src/queries/failed-imports/render.ts | 1 - src/queries/landing/render.ts | 1 - src/queries/log/render.ts | 1 - src/queries/members/render.ts | 1 - src/queries/super-users/render.ts | 1 - src/templates/logged-in-user-square.ts | 2 ++ src/templates/page-template.ts | 2 ++ tests/read-models/members/get.test.ts | 16 ++++++++-------- 22 files changed, 12 insertions(+), 28 deletions(-) diff --git a/src/commands/area/create-form.ts b/src/commands/area/create-form.ts index dbc57c18..4eb727b7 100644 --- a/src/commands/area/create-form.ts +++ b/src/commands/area/create-form.ts @@ -1,6 +1,5 @@ import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {User} from '../../types'; import {v4} from 'uuid'; import {Form} from '../../types/form'; diff --git a/src/commands/equipment/add-form.ts b/src/commands/equipment/add-form.ts index f150cf24..3d7672a5 100644 --- a/src/commands/equipment/add-form.ts +++ b/src/commands/equipment/add-form.ts @@ -2,7 +2,6 @@ import {pipe} from 'fp-ts/lib/function'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {DomainEvent, User} from '../../types'; import {v4} from 'uuid'; import {Form} from '../../types/form'; diff --git a/src/commands/equipment/register-training-sheet-form.ts b/src/commands/equipment/register-training-sheet-form.ts index d3f2c1ea..f8c52728 100644 --- a/src/commands/equipment/register-training-sheet-form.ts +++ b/src/commands/equipment/register-training-sheet-form.ts @@ -1,5 +1,4 @@ import {pipe} from 'fp-ts/lib/function'; -import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; import {User} from '../../types'; import {Form} from '../../types/form'; diff --git a/src/commands/member-numbers/link-number-to-email-form.ts b/src/commands/member-numbers/link-number-to-email-form.ts index 2726333b..5b0d8af9 100644 --- a/src/commands/member-numbers/link-number-to-email-form.ts +++ b/src/commands/member-numbers/link-number-to-email-form.ts @@ -1,6 +1,5 @@ import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {User} from '../../types'; import {Form} from '../../types/form'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/commands/members/edit-name-form.ts b/src/commands/members/edit-name-form.ts index e1ec185c..e30282ab 100644 --- a/src/commands/members/edit-name-form.ts +++ b/src/commands/members/edit-name-form.ts @@ -1,7 +1,6 @@ import {flow, pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {User} from '../../types'; import {Form} from '../../types/form'; import * as t from 'io-ts'; diff --git a/src/commands/members/edit-pronouns-form.ts b/src/commands/members/edit-pronouns-form.ts index f676872c..ffa77a3c 100644 --- a/src/commands/members/edit-pronouns-form.ts +++ b/src/commands/members/edit-pronouns-form.ts @@ -1,7 +1,6 @@ import {flow, pipe} from 'fp-ts/lib/function'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {User} from '../../types'; import {Form} from '../../types/form'; import * as t from 'io-ts'; diff --git a/src/commands/members/sign-owner-agreement-form.ts b/src/commands/members/sign-owner-agreement-form.ts index 04534610..d6b9507b 100644 --- a/src/commands/members/sign-owner-agreement-form.ts +++ b/src/commands/members/sign-owner-agreement-form.ts @@ -1,6 +1,5 @@ import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {Form} from '../../types/form'; import {User} from '../../types'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/commands/super-user/declare-form.ts b/src/commands/super-user/declare-form.ts index 0e0c4da4..e62f787f 100644 --- a/src/commands/super-user/declare-form.ts +++ b/src/commands/super-user/declare-form.ts @@ -1,6 +1,5 @@ import * as E from 'fp-ts/Either'; import {pipe} from 'fp-ts/lib/function'; -import * as O from 'fp-ts/Option'; import {pageTemplate} from '../../templates'; import {User, MemberDetails} from '../../types'; import {Form} from '../../types/form'; diff --git a/src/commands/super-user/revoke-form.ts b/src/commands/super-user/revoke-form.ts index da0c40fc..0380efb3 100644 --- a/src/commands/super-user/revoke-form.ts +++ b/src/commands/super-user/revoke-form.ts @@ -3,7 +3,6 @@ import * as tt from 'io-ts-types'; import * as t from 'io-ts'; import * as E from 'fp-ts/Either'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import {User} from '../../types'; import {failureWithStatus} from '../../types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; diff --git a/src/commands/trainers/mark-member-trained-form.ts b/src/commands/trainers/mark-member-trained-form.ts index 7a86cf78..1a32596c 100644 --- a/src/commands/trainers/mark-member-trained-form.ts +++ b/src/commands/trainers/mark-member-trained-form.ts @@ -1,6 +1,5 @@ import Handlebars, {SafeString} from 'handlebars'; import {pipe} from 'fp-ts/lib/function'; -import * as O from 'fp-ts/Option'; import * as E from 'fp-ts/Either'; import {User, MemberDetails} from '../../types'; import {Form} from '../../types/form'; diff --git a/src/queries/all-equipment/render.ts b/src/queries/all-equipment/render.ts index 3890ae46..cddd8d39 100644 --- a/src/queries/all-equipment/render.ts +++ b/src/queries/all-equipment/render.ts @@ -1,5 +1,3 @@ -import * as O from 'fp-ts/Option'; - import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/queries/area/render.ts b/src/queries/area/render.ts index 993b7a26..87222981 100644 --- a/src/queries/area/render.ts +++ b/src/queries/area/render.ts @@ -1,5 +1,4 @@ import {ViewModel} from './view-model'; -import * as O from 'fp-ts/Option'; import {pageTemplate} from '../../templates'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/queries/areas/render.ts b/src/queries/areas/render.ts index 11f85c39..72223829 100644 --- a/src/queries/areas/render.ts +++ b/src/queries/areas/render.ts @@ -1,4 +1,3 @@ -import * as O from 'fp-ts/Option'; import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/queries/equipment/render.ts b/src/queries/equipment/render.ts index e68f0ca2..d0b056fd 100644 --- a/src/queries/equipment/render.ts +++ b/src/queries/equipment/render.ts @@ -1,6 +1,5 @@ import {pageTemplate} from '../../templates'; import {ViewModel} from './view-model'; -import * as O from 'fp-ts/Option'; import Handlebars, {SafeString} from 'handlebars'; Handlebars.registerPartial( diff --git a/src/queries/failed-imports/render.ts b/src/queries/failed-imports/render.ts index 66464ab2..75b179e6 100644 --- a/src/queries/failed-imports/render.ts +++ b/src/queries/failed-imports/render.ts @@ -1,5 +1,4 @@ import {ViewModel} from './view-model'; -import * as O from 'fp-ts/Option'; import {pageTemplate} from '../../templates'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/queries/landing/render.ts b/src/queries/landing/render.ts index 0f2bf0fd..692fa45d 100644 --- a/src/queries/landing/render.ts +++ b/src/queries/landing/render.ts @@ -1,6 +1,5 @@ import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import Handlebars, {SafeString} from 'handlebars'; Handlebars.registerPartial( diff --git a/src/queries/log/render.ts b/src/queries/log/render.ts index 86938718..84838ae4 100644 --- a/src/queries/log/render.ts +++ b/src/queries/log/render.ts @@ -4,7 +4,6 @@ import {Actor} from '../../types/actor'; import {DomainEvent} from '../../types'; import {inspect} from 'node:util'; import {pageTemplate} from '../../templates'; -import * as O from 'fp-ts/Option'; import Handlebars, {SafeString} from 'handlebars'; Handlebars.registerHelper('render_actor', (actor: Actor) => { diff --git a/src/queries/members/render.ts b/src/queries/members/render.ts index 8c5cc69f..66ec904e 100644 --- a/src/queries/members/render.ts +++ b/src/queries/members/render.ts @@ -1,6 +1,5 @@ import {pageTemplate} from '../../templates'; import {ViewModel} from './view-model'; -import * as O from 'fp-ts/Option'; import Handlebars, {SafeString} from 'handlebars'; Handlebars.registerPartial( diff --git a/src/queries/super-users/render.ts b/src/queries/super-users/render.ts index 21f64028..5a6741e0 100644 --- a/src/queries/super-users/render.ts +++ b/src/queries/super-users/render.ts @@ -1,4 +1,3 @@ -import * as O from 'fp-ts/Option'; import {ViewModel} from './view-model'; import {pageTemplate} from '../../templates'; import Handlebars, {SafeString} from 'handlebars'; diff --git a/src/templates/logged-in-user-square.ts b/src/templates/logged-in-user-square.ts index d61c1330..32258160 100644 --- a/src/templates/logged-in-user-square.ts +++ b/src/templates/logged-in-user-square.ts @@ -1,3 +1,5 @@ +import Handlebars from 'handlebars'; + export const registerLoggedInUserSquare = () => { Handlebars.registerPartial( 'loggedInUserSquare', diff --git a/src/templates/page-template.ts b/src/templates/page-template.ts index 237c35b3..6428f1ab 100644 --- a/src/templates/page-template.ts +++ b/src/templates/page-template.ts @@ -9,6 +9,7 @@ import {registerMemberInput} from './member-input'; import {registerOptionalDetailHelper} from './detail'; import {registerMemberNumberHelper} from '../types/member-number'; import {registerDisplayDateHelper} from '../types/display-date'; +import {registerLoggedInUserSquare} from './logged-in-user-square'; registerNavBar(); registerHead(); @@ -19,6 +20,7 @@ registerDisplayDateHelper(); registerGridJs(); registerFilterListHelper(); registerMemberInput(); +registerLoggedInUserSquare(); const PAGE_TEMPLATE = Handlebars.compile(` diff --git a/tests/read-models/members/get.test.ts b/tests/read-models/members/get.test.ts index b196d3f4..ecef0b06 100644 --- a/tests/read-models/members/get.test.ts +++ b/tests/read-models/members/get.test.ts @@ -24,8 +24,8 @@ describe('getDetails', () => { const result = getDetails(42)(events); expect(result).toEqual( O.some({ - number: 42, - email: 'foo@example.com' as EmailAddress, + memberNumber: 42, + emailAddress: 'foo@example.com' as EmailAddress, name: O.none, pronouns: O.none, isSuperUser: false, @@ -48,8 +48,8 @@ describe('getDetails', () => { const result = getDetails(42)(events); expect(result).toEqual( O.some({ - number: 42, - email: 'foo@example.com', + memberNumber: 42, + emailAddress: 'foo@example.com', name: O.some('Ford Prefect'), pronouns: O.none, isSuperUser: false, @@ -80,8 +80,8 @@ describe('getDetails', () => { const result = getDetails(42)(events); expect(result).toEqual( O.some({ - number: 42, - email: 'foo@example.com', + memberNumber: 42, + emailAddress: 'foo@example.com', name: O.some('Ford Prefect'), pronouns: O.some('he/him'), isSuperUser: false, @@ -103,8 +103,8 @@ describe('getDetails', () => { const result = getDetails(42)(events); expect(result).toEqual( O.some({ - number: 42, - email: 'updated@example.com', + memberNumber: 42, + emailAddress: 'updated@example.com', name: O.none, pronouns: O.none, isSuperUser: false, From f5e02ab0a4beca3ab86b733a79debe87d202af8e Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Mon, 15 Jul 2024 23:05:03 +0100 Subject: [PATCH 36/38] Reduce indent --- src/templates/navbar.ts | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/src/templates/navbar.ts b/src/templates/navbar.ts index ebfcb396..9fd60579 100644 --- a/src/templates/navbar.ts +++ b/src/templates/navbar.ts @@ -4,21 +4,21 @@ export const registerNavBar = () => { Handlebars.registerPartial( 'navbar', ` - - ` + + ` ); }; From 38b48b35dfcccbd027ca819e3a598ff291b45910 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Tue, 16 Jul 2024 19:56:16 +0100 Subject: [PATCH 37/38] Fixed calling loggedInUserSquare template --- src/templates/navbar.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/navbar.ts b/src/templates/navbar.ts index 9fd60579..1151118b 100644 --- a/src/templates/navbar.ts +++ b/src/templates/navbar.ts @@ -17,7 +17,7 @@ export const registerNavBar = () => { Equipment Areas Log out - {{loggedInUserSquare user}} + {{> loggedInUserSquare user}} ` ); From 3cd2a8e35640bec80c8943e38bad5f64a9f10659 Mon Sep 17 00:00:00 2001 From: Paul Lancaster Date: Tue, 16 Jul 2024 19:59:59 +0100 Subject: [PATCH 38/38] Logged in user square working --- src/templates/logged-in-user-square.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/templates/logged-in-user-square.ts b/src/templates/logged-in-user-square.ts index 32258160..6819ad9b 100644 --- a/src/templates/logged-in-user-square.ts +++ b/src/templates/logged-in-user-square.ts @@ -5,7 +5,7 @@ export const registerLoggedInUserSquare = () => { 'loggedInUserSquare', ` - {{avatar_thumbnail member=user}} + {{avatar_thumbnail this.emailAddress this.memberNumber}} ` );