From a5e7c4a295ad71685e1c4a15130cb42159119368 Mon Sep 17 00:00:00 2001 From: Daniel Emery Date: Mon, 6 Nov 2023 21:28:48 +0100 Subject: [PATCH] Add basic statistics endpoint (#69) * #66 Add generic arguments and documentation to the cache logic * #66 Add persistence function to load completions and quiz types for a user * #66 Add new quiz functions that will be needed for statistics calculations * #66 Add new statistics service to compute statistics for every quizlord user * #66 Expose new statistics resolver to get individual user statistics * #66 Populate the cache on a successful call to getIndividualUserStatistics * Allow provided empty string for sentry (for local development) --- src/config/config.ts | 2 +- src/gql.ts | 8 +++ src/index.ts | 2 + src/quiz/quiz.persistence.ts | 34 +++++++++++ src/quiz/quiz.service.ts | 55 +++++++++++++++++ src/service.locator.ts | 7 +++ src/statistics/statistics.dto.ts | 6 ++ src/statistics/statistics.gql.ts | 16 +++++ src/statistics/statistics.service.ts | 89 ++++++++++++++++++++++++++++ src/user/user.gql.ts | 2 +- src/user/user.service.ts | 6 +- src/util/cache.ts | 42 +++++++++++-- 12 files changed, 259 insertions(+), 10 deletions(-) create mode 100644 src/statistics/statistics.dto.ts create mode 100644 src/statistics/statistics.gql.ts create mode 100644 src/statistics/statistics.service.ts diff --git a/src/config/config.ts b/src/config/config.ts index 18d0997..9699b0e 100644 --- a/src/config/config.ts +++ b/src/config/config.ts @@ -35,7 +35,7 @@ const schema = Joi.object() AWS_FILE_UPLOADED_SQS_QUEUE_URL: Joi.string().required(), FILE_ACCESS_BASE_URL: Joi.string().required(), QUIZLORD_VERSION: Joi.string().default('development'), - SENTRY_DSN: Joi.string().required(), + SENTRY_DSN: Joi.string().required().allow(''), DOPPLER_CONFIG: Joi.string().required(), }) .required() diff --git a/src/gql.ts b/src/gql.ts index 85e03f5..f385b16 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -116,6 +116,13 @@ const typeDefs = gql` completion: QuizCompletion } + type IndividualUserStatistic { + name: String + email: String! + totalQuizCompletions: Int! + averageScorePercentage: Float! + } + "Available filters for the quizzes query" input QuizFilters { """ @@ -144,6 +151,7 @@ const typeDefs = gql` """ users(first: Int, after: String, sortedBy: UserSortOption): UserConnection me: UserDetails + individualUserStatistics: [IndividualUserStatistic] } type Mutation { diff --git a/src/index.ts b/src/index.ts index 9484164..34ab6d7 100644 --- a/src/index.ts +++ b/src/index.ts @@ -14,6 +14,7 @@ import typeDefs from './gql'; import { userQueries } from './user/user.gql'; import { quizMutations, quizQueries } from './quiz/quiz.gql'; import { Role } from './user/user.dto'; +import { statisticsQueries } from './statistics/statistics.gql'; const QUIZLORD_VERSION_HEADER = 'X-Quizlord-Api-Version'; @@ -39,6 +40,7 @@ const resolvers = { Query: { ...quizQueries, ...userQueries, + ...statisticsQueries, }, Mutation: { ...quizMutations, diff --git a/src/quiz/quiz.persistence.ts b/src/quiz/quiz.persistence.ts index f860d44..abc985f 100644 --- a/src/quiz/quiz.persistence.ts +++ b/src/quiz/quiz.persistence.ts @@ -174,4 +174,38 @@ export class QuizPersistence { }); return result; } + + async getCompletionScoreWithQuizTypesForUser({ + email, + afterId, + limit, + }: { + email: string; + afterId?: string; + limit: number; + }) { + const result = await this.#prisma.client().quizCompletion.findMany({ + ...getPagedQuery(limit, afterId), + orderBy: { + id: 'desc', + }, + where: { + completedBy: { + some: { + user: { + email, + }, + }, + }, + }, + include: { + quiz: { + select: { + type: true, + }, + }, + }, + }); + return slicePagedResults(result, limit, afterId !== undefined); + } } diff --git a/src/quiz/quiz.service.ts b/src/quiz/quiz.service.ts index 1a43924..ea9afa8 100644 --- a/src/quiz/quiz.service.ts +++ b/src/quiz/quiz.service.ts @@ -137,6 +137,61 @@ export class QuizService { await this.#persistence.markQuizImageReady(imageKey); } + /** + * Get the max score for a quiz type. + * @param quizType The type of quiz to get the max score for. + * @returns The max score for the quiz type. + */ + getMaxScoreForQuizType(quizType: QuizType) { + switch (quizType) { + case 'BRAINWAVES': + return 50; + case 'SHARK': + return 20; + default: + throw new Error(`Unknown quizType ${quizType}`); + } + } + + /** + * Get a paginated list of quiz percentages for a user. + * @param filters Paging and filtering options. + * @returns A list of quiz percentages (as a number between 0 and 1) for the user in stats and a cursor to load the next set of scores. + */ + async quizScorePercentagesForUser({ + email, + first = 100, + afterId, + }: { + /** + * The email of the user to get quiz percentages for. + */ + email: string; + /** + * The number of quiz percentages to get. + */ + first: number; + /** + * The cursor to start getting quiz percentages from. + * If not provided, the first quiz percentage will be returned. + * Will have been returned in the previous call to this function. + */ + afterId?: string; + }) { + const { data, hasMoreRows } = await this.#persistence.getCompletionScoreWithQuizTypesForUser({ + email, + limit: first, + afterId, + }); + return { + stats: data.map((completion) => { + const maxScore = this.getMaxScoreForQuizType(completion.quiz.type); + return completion.score.toNumber() / maxScore; + }), + cursor: hasMoreRows ? data[data.length - 1]?.id : undefined, + }; + } + async #populateFileWithUploadLink(file: { fileName: string; type: QuizImageType; imageKey: string }) { const uploadLink = await this.#fileService.generateSignedUploadUrl(file.imageKey); return { diff --git a/src/service.locator.ts b/src/service.locator.ts index 1d4b35f..7d8bd0f 100644 --- a/src/service.locator.ts +++ b/src/service.locator.ts @@ -5,8 +5,12 @@ import { S3FileService } from './file/s3.service'; import { SQSQueueService } from './queue/sqs.service'; import { QuizPersistence } from './quiz/quiz.persistence'; import { QuizService } from './quiz/quiz.service'; +import { StatisticsService } from './statistics/statistics.service'; import { UserPersistence } from './user/user.persistence'; import { UserService } from './user/user.service'; +import { MemoryCache } from './util/cache'; + +const memoryCache = new MemoryCache(); // auth export const authenticationService = new AuthenticationService(); @@ -28,3 +32,6 @@ export const userService = new UserService(userPersistence); // queue export const queueService = new SQSQueueService(quizService); + +// statistics +export const statisticsService = new StatisticsService(userService, quizService, memoryCache); diff --git a/src/statistics/statistics.dto.ts b/src/statistics/statistics.dto.ts new file mode 100644 index 0000000..3e760ad --- /dev/null +++ b/src/statistics/statistics.dto.ts @@ -0,0 +1,6 @@ +export interface IndividualUserStatistic { + name?: string; + email: string; + totalQuizCompletions: number; + averageScorePercentage: number; +} diff --git a/src/statistics/statistics.gql.ts b/src/statistics/statistics.gql.ts new file mode 100644 index 0000000..8318ca3 --- /dev/null +++ b/src/statistics/statistics.gql.ts @@ -0,0 +1,16 @@ +import { QuizlordContext } from '..'; +import { authorisationService, statisticsService } from '../service.locator'; +import { IndividualUserStatistic } from './statistics.dto'; + +async function individualUserStatistics( + _p: unknown, + _: void, + context: QuizlordContext, +): Promise { + authorisationService.requireUserRole(context, 'USER'); + return statisticsService.getIndividualUserStatistics(); +} + +export const statisticsQueries = { + individualUserStatistics, +}; diff --git a/src/statistics/statistics.service.ts b/src/statistics/statistics.service.ts new file mode 100644 index 0000000..ff4f943 --- /dev/null +++ b/src/statistics/statistics.service.ts @@ -0,0 +1,89 @@ +import { QuizService } from '../quiz/quiz.service'; +import { UserService } from '../user/user.service'; +import { Cache } from '../util/cache'; +import { IndividualUserStatistic } from './statistics.dto'; + +const INDIVIDUAL_STATISTICS_CACHE_KEY = 'invidual-user-statistics'; +const INDIVIDUAL_STATISTICS_CACHE_TTL = 60 * 60 * 1000; // 24 hours + +export class StatisticsService { + #userService: UserService; + #quizService: QuizService; + #cache: Cache; + constructor(userService: UserService, quizService: QuizService, cache: Cache) { + this.#userService = userService; + this.#quizService = quizService; + this.#cache = cache; + } + + /** + * Gets the individual statistics for all users. + * @returns An array of users with their statistics. + * + * @tags worker + */ + async getIndividualUserStatistics(): Promise { + const cachedResult = await this.#cache.getItem(INDIVIDUAL_STATISTICS_CACHE_KEY); + if (cachedResult) { + return cachedResult; + } + + const results: IndividualUserStatistic[] = []; + let hasMoreRows = true; + let cursor: string | undefined = undefined; + while (hasMoreRows) { + const { data, hasMoreRows: moreRows } = await this.#userService.getUsers({ + currentUserId: '1', // Current user id isn't valid here and isn't used for sorting by EMAIL_ASC + first: 100, + afterId: cursor, + sortedBy: 'EMAIL_ASC', + }); + + for (const user of data) { + const { totalQuizCompletions, averageScorePercentage } = await this.#getStatisticsForUser(user.email); + results.push({ + email: user.email, + name: user.name, + totalQuizCompletions, + averageScorePercentage, + }); + } + + cursor = data[data.length - 1]?.id; + hasMoreRows = moreRows; + } + + await this.#cache.setItem(INDIVIDUAL_STATISTICS_CACHE_KEY, results, INDIVIDUAL_STATISTICS_CACHE_TTL); + return results; + } + + /** + * Get quiz completion statistics for a single user. + * @param userEmail The email of the user to get statistics for. + * @returns Total quiz completions and average score percentage for the user. + * + * @tags worker + */ + async #getStatisticsForUser(userEmail: string) { + let hasMoreRows = true; + let cursor: string | undefined = undefined; + const completionsScores: number[] = []; + while (hasMoreRows) { + const { stats, cursor: latestCursor } = await this.#quizService.quizScorePercentagesForUser({ + email: userEmail, + first: 100, + afterId: cursor, + }); + + completionsScores.push(...stats); + + cursor = latestCursor; + hasMoreRows = !!latestCursor; + } + + return { + totalQuizCompletions: completionsScores.length, + averageScorePercentage: completionsScores.reduce((a, b) => a + b, 0) / completionsScores.length, + }; + } +} diff --git a/src/user/user.gql.ts b/src/user/user.gql.ts index fea3991..d2d1b12 100644 --- a/src/user/user.gql.ts +++ b/src/user/user.gql.ts @@ -15,7 +15,7 @@ async function users( authorisationService.requireUserRole(context, 'USER'); const afterId = after ? base64Decode(after) : undefined; const { data, hasMoreRows } = await userService.getUsers({ - userId: context.userId, + currentUserId: context.userId, afterId, first, sortedBy, diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 32fb023..59d44b4 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -35,12 +35,12 @@ export class UserService { } async getUsers({ - userId, + currentUserId, first, afterId, sortedBy, }: { - userId: string; + currentUserId: string; first: number; afterId?: string; sortedBy: UserSortOption; @@ -50,7 +50,7 @@ export class UserService { afterId, limit: first, sortedBy, - currentUserId: userId, + currentUserId, }); return { data: data.map((user) => this.#userPersistenceToUser(user)), diff --git a/src/util/cache.ts b/src/util/cache.ts index ac0baed..ea9a54f 100644 --- a/src/util/cache.ts +++ b/src/util/cache.ts @@ -1,19 +1,51 @@ +/** + * Cache interface defining the methods that must be implemented by a cache. + */ export interface Cache { - getItem(key: string): Promise; - setItem(key: string, value: string, expiresInSeconds: number): Promise; + /** + * Get an item from the cache. + * + * A generic argument is required to specify the expected type of the item. + * Note that there is no guarantee that the item will be of this type. + * + * @param key The key to get the item for. + * @returns The item if it exists, otherwise undefined. + */ + getItem(key: string): Promise; + + /** + * Set an item in the cache. If the item already exists, it will be overwritten and the expiry updated. + * + * A generic argument is required to specify the type of the item being stored. + * + * @param key The key to set the item for. + * @param value The value to set. + * @param expiresInSeconds The number of seconds until the item expires. + */ + setItem(key: string, value: T, expiresInSeconds: number): Promise; + + /** + * Manually expire an item in the cache. + * @param key The key to expire. + */ expireItem(key: string): Promise; } +/** + * Placeholder cache implementation that just stores values in memory. + * Will be replaced with a Redis implementation in the future. + */ export class MemoryCache implements Cache { - #values: Map = new Map(); - getItem(key: string): Promise { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + #values: Map = new Map(); + getItem(key: string): Promise { const record = this.#values.get(key); if (!record || (record.expiresAt && new Date().getTime() > record.expiresAt.getTime())) { return Promise.resolve(undefined); } return Promise.resolve(record.value); } - setItem(key: string, value: string, expiresInSeconds: number): Promise { + setItem(key: string, value: T, expiresInSeconds: number): Promise { const expiresAt = new Date(new Date().getTime() + expiresInSeconds * 1000); this.#values.set(key, { value,