Skip to content

Commit

Permalink
Types to link into the shared state cache
Browse files Browse the repository at this point in the history
  • Loading branch information
Lan2u committed Sep 15, 2024
1 parent 892dd65 commit a4e8d3e
Show file tree
Hide file tree
Showing 9 changed files with 122 additions and 112 deletions.
8 changes: 0 additions & 8 deletions src/configuration.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 2 additions & 5 deletions src/dependencies.ts
Original file line number Diff line number Diff line change
@@ -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: (
Expand Down Expand Up @@ -38,7 +37,5 @@ export type Dependencies = {
logger: Logger;
rateLimitSendingOfEmails: (email: Email) => TE.TaskEither<Failure, Email>;
sendEmail: (email: Email) => TE.TaskEither<Failure, string>;
updateTrainingQuizResults: O.Option<() => Promise<void>>;
lastTrainingQuizResultRefresh: O.Option<DateTime>;
trainingQuizRefreshRunning: boolean;
pullGoogleSheetData: (logger: Logger, trainingSheetId: string) => TE.TaskEither<Failure, sheets_v4.Schema$Spreadsheet>;
};
13 changes: 0 additions & 13 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -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()()
Expand All @@ -74,7 +62,6 @@ const periodicReadModelRefresh = setInterval(() => {
);
}, 5000);
server.on('close', () => {
clearInterval(backgroundTask);
clearInterval(periodicReadModelRefresh);
});

Expand Down
38 changes: 10 additions & 28 deletions src/init-dependencies/init-dependencies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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;
};
42 changes: 2 additions & 40 deletions src/queries/equipment/view-model.ts
Original file line number Diff line number Diff line change
@@ -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<QuizID>;
};

export type QuizResultUnknownMemberViewModel = {
id: QuizID;
score: number;
maxScore: number;
percentage: number;
passed: boolean;
timestamp: DateTime;

memberNumberProvided: O.Option<number>;
emailProvided: O.Option<string>;
};
import { TrainingQuizResults } from '../../read-models/shared-state/training-results';

export type ViewModel = {
user: User;
isSuperUserOrOwnerOfArea: boolean;
isSuperUserOrTrainerOfArea: boolean;
equipment: Equipment;
trainingQuizResults: {
lastRefresh: O.Option<DateTime>;
quizPassedNotTrained: {
knownMember: ReadonlyArray<QuizResultViewModel>;
unknownMember: ReadonlyArray<QuizResultUnknownMemberViewModel>;
};
failedQuizNotTrained: {
knownMember: ReadonlyArray<QuizResultViewModel>;
};
};
trainingQuizResults: TrainingQuizResults;
};
17 changes: 0 additions & 17 deletions src/read-models/equipment/get-training-quiz-results.ts

This file was deleted.

7 changes: 6 additions & 1 deletion src/read-models/shared-state/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -20,11 +22,13 @@ export type SharedReadModel = {
};
equipment: {
get: (id: string) => O.Option<Equipment>;
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));
Expand All @@ -36,6 +40,7 @@ export const initSharedReadModel = (
},
equipment: {
get: getEquipment(readModelDb),
getTrainingQuizResults: getTrainingQuizResults(readModelDb),
},
};
};
51 changes: 51 additions & 0 deletions src/read-models/shared-state/state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'];
Expand Down Expand Up @@ -117,13 +118,47 @@ 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,
createTrainersTable,
createTrainedMembersTable,
createAreasTable,
createOwnersTable,
createTrainingQuizTable,
];

type Member = {
Expand All @@ -143,10 +178,26 @@ type Area = {
owners: Set<number>;
};

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<TrainedUser>;
usersAwaitingTraining: ReadonlyArray<UserAwaitingTraining>;
orphanedPassedQuizes: ReadonlyArray<UserAwaitingTraining>;
};

type FailedLinking = {
Expand Down
51 changes: 51 additions & 0 deletions src/read-models/shared-state/training-results.ts
Original file line number Diff line number Diff line change
@@ -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<QuizID>;
};

export type QuizResultUnknownMemberViewModel = {
id: QuizID;
score: number;
maxScore: number;
percentage: number;
passed: boolean;
timestamp: DateTime;

memberNumberProvided: O.Option<number>;
emailProvided: O.Option<string>;
};

export interface TrainingQuizResults {
lastRefresh: O.Option<DateTime>;
quizPassedNotTrained: {
knownMember: ReadonlyArray<QuizResultViewModel>;
unknownMember: ReadonlyArray<QuizResultUnknownMemberViewModel>;
};
failedQuizNotTrained: {
knownMember: ReadonlyArray<QuizResultViewModel>;
};
}

export const getTrainingQuizResults = (db: BetterSQLite3Database): SharedReadModel['equipment']['getTrainingQuizResults'] => async (equipmentId: string): Promise<O.Option<TrainingQuizResults>> => {

}

0 comments on commit a4e8d3e

Please sign in to comment.