diff --git a/src/configuration.ts b/src/configuration.ts index 88bc884f..d95acb47 100644 --- a/src/configuration.ts +++ b/src/configuration.ts @@ -42,14 +42,6 @@ const Config = t.strict({ TURSO_TOKEN: t.union([t.undefined, t.string]), TURSO_SYNC_URL: t.union([t.undefined, t.string]), LOG_LEVEL: withDefaultIfEmpty(LogLevel, 'debug'), - BACKGROUND_PROCESSING_ENABLED: withDefaultIfEmpty( - tt.BooleanFromString, - false - ), - BACKGROUND_PROCESSING_RUN_INTERVAL_MS: withDefaultIfEmpty( - tt.IntFromString, - (30 * 60 * 1000) as t.Int - ), QUIZ_RESULT_REFRESH_COOLDOWN_MS: withDefaultIfEmpty( tt.IntFromString, (5 * 60 * 1000) as t.Int diff --git a/src/dependencies.ts b/src/dependencies.ts index edbabbec..3f332ec7 100644 --- a/src/dependencies.ts +++ b/src/dependencies.ts @@ -1,14 +1,13 @@ import {Logger} from 'pino'; import {Failure, Email, DomainEvent, ResourceVersion} from './types'; import * as TE from 'fp-ts/TaskEither'; -import * as O from 'fp-ts/Option'; import {FailureWithStatus} from './types/failure-with-status'; import {StatusCodes} from 'http-status-codes'; import {Resource} from './types/resource'; import {EventName, EventOfType} from './types/domain-event'; -import {DateTime} from 'luxon'; import {SharedReadModel} from './read-models/shared-state'; +import { sheets_v4 } from '@googleapis/sheets'; export type Dependencies = { commitEvent: ( @@ -38,7 +37,5 @@ export type Dependencies = { logger: Logger; rateLimitSendingOfEmails: (email: Email) => TE.TaskEither; sendEmail: (email: Email) => TE.TaskEither; - updateTrainingQuizResults: O.Option<() => Promise>; - lastTrainingQuizResultRefresh: O.Option; - trainingQuizRefreshRunning: boolean; + pullGoogleSheetData: (logger: Logger, trainingSheetId: string) => TE.TaskEither; }; diff --git a/src/index.ts b/src/index.ts index 395e23a9..db3d0b97 100644 --- a/src/index.ts +++ b/src/index.ts @@ -18,7 +18,6 @@ import {initDependencies} from './init-dependencies'; import * as libsqlClient from '@libsql/client'; import cookieSession from 'cookie-session'; import {initRoutes} from './routes'; -import * as O from 'fp-ts/Option'; // Dependencies and Config const conf = loadConfig(); @@ -54,17 +53,6 @@ startMagicLinkEmailPubSub(deps, conf); const server = http.createServer(app); createTerminus(server); -const backgroundTask = setInterval(() => { - if (O.isNone(deps.updateTrainingQuizResults)) { - deps.logger.info('Background task skipped as disabled'); - return; - } - deps.logger.info('Background task running...'); - deps.updateTrainingQuizResults - .value() - .then(() => deps.logger.info('Background update of quiz results finished')) - .catch(err => deps.logger.error(err, 'Background update unexpected error')); -}, conf.BACKGROUND_PROCESSING_RUN_INTERVAL_MS); const periodicReadModelRefresh = setInterval(() => { deps.sharedReadModel .asyncRefresh()() @@ -74,7 +62,6 @@ const periodicReadModelRefresh = setInterval(() => { ); }, 5000); server.on('close', () => { - clearInterval(backgroundTask); clearInterval(periodicReadModelRefresh); }); diff --git a/src/init-dependencies/init-dependencies.ts b/src/init-dependencies/init-dependencies.ts index a1376123..01194e5d 100644 --- a/src/init-dependencies/init-dependencies.ts +++ b/src/init-dependencies/init-dependencies.ts @@ -60,6 +60,12 @@ export const initDependencies = ( const sharedReadModel = initSharedReadModel(dbClient); + if (!conf.GOOGLE_SERVICE_ACCOUNT_KEY_JSON) { + throw new Error( + 'Google service account key not provided' + ); + } + const deps: Dependencies = { commitEvent: commitEvent(dbClient, logger, sharedReadModel.asyncRefresh), getAllEvents: getAllEvents(dbClient), @@ -69,37 +75,13 @@ export const initDependencies = ( rateLimitSendingOfEmails: createRateLimiter(5, 24 * 3600), sendEmail: sendEmail(emailTransporter, conf.SMTP_FROM), logger, - updateTrainingQuizResults: O.none, - lastTrainingQuizResultRefresh: O.none, - trainingQuizRefreshRunning: false, - }; - - if (conf.BACKGROUND_PROCESSING_ENABLED) { - if (!conf.GOOGLE_SERVICE_ACCOUNT_KEY_JSON) { - throw new Error( - 'Background processing is enabled but google service account key not provided' - ); - } - const auth = new GoogleAuth({ + pullGoogleSheetData: pullGoogleSheetData( + new GoogleAuth({ // Google issues the credentials file and validates it. // eslint-disable-next-line @typescript-eslint/no-unsafe-assignment credentials: JSON.parse(conf.GOOGLE_SERVICE_ACCOUNT_KEY_JSON), scopes: ['https://www.googleapis.com/auth/spreadsheets.readonly'], - }); - deps.updateTrainingQuizResults = O.some(() => - updateTrainingQuizResults( - pullGoogleSheetData(auth), - deps, - logger, - conf.QUIZ_RESULT_REFRESH_COOLDOWN_MS, - conf.LEGACY_TRAINING_COMPLETE_SHEET, - ) - ); - } else { - logger.warn( - "Background processing is disabled - training results won't be gathered" - ); - } - + })), + }; return deps; }; diff --git a/src/queries/equipment/view-model.ts b/src/queries/equipment/view-model.ts index 966c16f7..e296e71d 100644 --- a/src/queries/equipment/view-model.ts +++ b/src/queries/equipment/view-model.ts @@ -1,49 +1,11 @@ -import {DateTime} from 'luxon'; import {User} from '../../types'; -import * as O from 'fp-ts/Option'; -import {UUID} from 'io-ts-types'; import {Equipment} from '../../read-models/equipment/get'; - -type QuizID = UUID; - -export type QuizResultViewModel = { - id: QuizID; - score: number; - maxScore: number; - percentage: number; - passed: boolean; - timestamp: DateTime; - - memberNumber: number; - - otherAttempts: ReadonlyArray; -}; - -export type QuizResultUnknownMemberViewModel = { - id: QuizID; - score: number; - maxScore: number; - percentage: number; - passed: boolean; - timestamp: DateTime; - - memberNumberProvided: O.Option; - emailProvided: O.Option; -}; +import { TrainingQuizResults } from '../../read-models/shared-state/training-results'; export type ViewModel = { user: User; isSuperUserOrOwnerOfArea: boolean; isSuperUserOrTrainerOfArea: boolean; equipment: Equipment; - trainingQuizResults: { - lastRefresh: O.Option; - quizPassedNotTrained: { - knownMember: ReadonlyArray; - unknownMember: ReadonlyArray; - }; - failedQuizNotTrained: { - knownMember: ReadonlyArray; - }; - }; + trainingQuizResults: TrainingQuizResults; }; diff --git a/src/read-models/equipment/get-training-quiz-results.ts b/src/read-models/equipment/get-training-quiz-results.ts deleted file mode 100644 index 89aaaf22..00000000 --- a/src/read-models/equipment/get-training-quiz-results.ts +++ /dev/null @@ -1,17 +0,0 @@ -import {pipe} from 'fp-ts/lib/function'; -import * as RA from 'fp-ts/ReadonlyArray'; -import {DomainEvent, isEventOfType} from '../../types'; -import {EventOfType} from '../../types/domain-event'; - -export const getTrainingQuizResults = - (events: ReadonlyArray) => - ( - equipmentId: string - ): ReadonlyArray> => - pipe( - events, - RA.filter(isEventOfType('EquipmentTrainingQuizResult')), - RA.filter(event => { - return event.equipmentId === equipmentId; - }) - ); diff --git a/src/read-models/shared-state/index.ts b/src/read-models/shared-state/index.ts index 90507421..4fcaacd6 100644 --- a/src/read-models/shared-state/index.ts +++ b/src/read-models/shared-state/index.ts @@ -3,12 +3,14 @@ import * as O from 'fp-ts/Option'; import {createTables} from './state'; import {BetterSQLite3Database, drizzle} from 'drizzle-orm/better-sqlite3'; import Database from 'better-sqlite3'; +import { GoogleAuth } from 'google-auth-library'; import {getMember} from './get-member'; import {Equipment, Member} from './return-types'; import {getEquipment} from './get-equipment'; import {Client} from '@libsql/client/.'; import {asyncRefresh} from './async-refresh'; import {updateState} from './update-state'; +import { TrainingQuizResults, getTrainingQuizResults } from './training-results'; export {replayState} from './deprecated-replay'; @@ -20,11 +22,13 @@ export type SharedReadModel = { }; equipment: { get: (id: string) => O.Option; + getTrainingQuizResults: (equipmentId: string) => TrainingQuizResults; }; }; export const initSharedReadModel = ( - eventStoreClient: Client + eventStoreClient: Client, + googleAuth: GoogleAuth, ): SharedReadModel => { const readModelDb = drizzle(new Database()); createTables.forEach(statement => readModelDb.run(statement)); @@ -36,6 +40,7 @@ export const initSharedReadModel = ( }, equipment: { get: getEquipment(readModelDb), + getTrainingQuizResults: getTrainingQuizResults(readModelDb), }, }; }; diff --git a/src/read-models/shared-state/state.ts b/src/read-models/shared-state/state.ts index e7569657..fb6a16bf 100644 --- a/src/read-models/shared-state/state.ts +++ b/src/read-models/shared-state/state.ts @@ -2,6 +2,7 @@ import {sql} from 'drizzle-orm'; import {EmailAddress, GravatarHash} from '../../types'; import * as O from 'fp-ts/Option'; import {blob, integer, sqliteTable, text} from 'drizzle-orm/sqlite-core'; +import { DateTime } from 'luxon'; type TrainedOn = { id: Equipment['id']; @@ -117,6 +118,39 @@ const createOwnersTable = sql` ) `; +export const trainingQuizTable = sqliteTable('trainingQuizResults', { + quizId: text('quizId').notNull().primaryKey(), + equipmentId: text('equipmentId').notNull().references(() => equipmentTable.id), + sheetId: text('sheetId').notNull(), + // Member number is the confirmed member number for the quiz. + // If its null then we haven't successfully linked this quiz result to a member. + memberNumber: integer('memberNumber').references(() => membersTable.memberNumber), + memberNumberProvided: integer('memberNumberProvided'), + emailProvided: text('email'), + score: integer('score').notNull(), + maxScore: integer('maxScore').notNull(), + // Rounded up. + percentage: integer('percentage').notNull(), + passed: integer('passed', {'mode': 'boolean'}).notNull().default(false), + timestamp: integer('timestamp', {'mode': 'timestamp'}).notNull(), +}); + +const createTrainingQuizTable = sql` + CREATE TABLE IF NOT EXISTS trainingQuizResults ( + quizId TEXT, + equipmentId TEXT, + sheetId: TEXT, + memberNumber INTEGER, + memberNumberProvided INTEGER, + emailProvided TEXT, + score INTEGER, + maxScore INTEGER, + percentage INTEGER, + passed BOOLEAN, + timestamp INTEGER + ) +`; + export const createTables = [ createMembersTable, createEquipmentTable, @@ -124,6 +158,7 @@ export const createTables = [ createTrainedMembersTable, createAreasTable, createOwnersTable, + createTrainingQuizTable, ]; type Member = { @@ -143,10 +178,26 @@ type Area = { owners: Set; }; +type UserAwaitingTraining = { + // Implies the user has passed the quiz. + quizId: string; + memberNumber: number; + waitingSince: DateTime; +} + +type TrainedUser = { + memberNumber: number, + trainedSince: DateTime, + trainedByMemberNumber: number; +} + type Equipment = { id: string; name: string; areaId: Area['id']; + trainedUsers: ReadonlyArray; + usersAwaitingTraining: ReadonlyArray; + orphanedPassedQuizes: ReadonlyArray; }; type FailedLinking = { diff --git a/src/read-models/shared-state/training-results.ts b/src/read-models/shared-state/training-results.ts new file mode 100644 index 00000000..eb228422 --- /dev/null +++ b/src/read-models/shared-state/training-results.ts @@ -0,0 +1,51 @@ +// Similar to the main shared-state but since the data comes from +// google rather than the database its written separately initially. + +import * as O from 'fp-ts/Option'; +import { GoogleAuth } from 'google-auth-library'; +import { DateTime } from 'luxon'; +import { SharedReadModel } from '.'; +import { BetterSQLite3Database } from 'drizzle-orm/better-sqlite3'; +import { UUID } from 'io-ts-types'; + +type QuizID = UUID; + +export type QuizResultViewModel = { + id: QuizID; + score: number; + maxScore: number; + percentage: number; + passed: boolean; + timestamp: DateTime; + + memberNumber: number; + + otherAttempts: ReadonlyArray; +}; + +export type QuizResultUnknownMemberViewModel = { + id: QuizID; + score: number; + maxScore: number; + percentage: number; + passed: boolean; + timestamp: DateTime; + + memberNumberProvided: O.Option; + emailProvided: O.Option; +}; + +export interface TrainingQuizResults { + lastRefresh: O.Option; + quizPassedNotTrained: { + knownMember: ReadonlyArray; + unknownMember: ReadonlyArray; + }; + failedQuizNotTrained: { + knownMember: ReadonlyArray; + }; +} + +export const getTrainingQuizResults = (db: BetterSQLite3Database): SharedReadModel['equipment']['getTrainingQuizResults'] => async (equipmentId: string): Promise> => { + +} \ No newline at end of file