Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add basic statistics endpoint #69

Merged
merged 7 commits into from
Nov 6, 2023
2 changes: 1 addition & 1 deletion src/config/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ const schema = Joi.object<QuizlordConfig>()
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()
Expand Down
8 changes: 8 additions & 0 deletions src/gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
"""
Expand Down Expand Up @@ -144,6 +151,7 @@ const typeDefs = gql`
"""
users(first: Int, after: String, sortedBy: UserSortOption): UserConnection
me: UserDetails
individualUserStatistics: [IndividualUserStatistic]
}

type Mutation {
Expand Down
2 changes: 2 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';

Expand All @@ -39,6 +40,7 @@ const resolvers = {
Query: {
...quizQueries,
...userQueries,
...statisticsQueries,
},
Mutation: {
...quizMutations,
Expand Down
34 changes: 34 additions & 0 deletions src/quiz/quiz.persistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
}
}
55 changes: 55 additions & 0 deletions src/quiz/quiz.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
7 changes: 7 additions & 0 deletions src/service.locator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -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);
6 changes: 6 additions & 0 deletions src/statistics/statistics.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
export interface IndividualUserStatistic {
name?: string;
email: string;
totalQuizCompletions: number;
averageScorePercentage: number;
}
16 changes: 16 additions & 0 deletions src/statistics/statistics.gql.ts
Original file line number Diff line number Diff line change
@@ -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<IndividualUserStatistic[]> {
authorisationService.requireUserRole(context, 'USER');
return statisticsService.getIndividualUserStatistics();
}

export const statisticsQueries = {
individualUserStatistics,
};
89 changes: 89 additions & 0 deletions src/statistics/statistics.service.ts
Original file line number Diff line number Diff line change
@@ -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<IndividualUserStatistic[]> {
const cachedResult = await this.#cache.getItem<IndividualUserStatistic[]>(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,
};
}
}
2 changes: 1 addition & 1 deletion src/user/user.gql.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
6 changes: 3 additions & 3 deletions src/user/user.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,12 +35,12 @@ export class UserService {
}

async getUsers({
userId,
currentUserId,
first,
afterId,
sortedBy,
}: {
userId: string;
currentUserId: string;
first: number;
afterId?: string;
sortedBy: UserSortOption;
Expand All @@ -50,7 +50,7 @@ export class UserService {
afterId,
limit: first,
sortedBy,
currentUserId: userId,
currentUserId,
});
return {
data: data.map((user) => this.#userPersistenceToUser(user)),
Expand Down
42 changes: 37 additions & 5 deletions src/util/cache.ts
Original file line number Diff line number Diff line change
@@ -1,19 +1,51 @@
/**
* Cache interface defining the methods that must be implemented by a cache.
*/
export interface Cache {
getItem(key: string): Promise<string | undefined>;
setItem(key: string, value: string, expiresInSeconds: number): Promise<void>;
/**
* 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<T>(key: string): Promise<T | undefined>;

/**
* 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<T>(key: string, value: T, expiresInSeconds: number): Promise<void>;

/**
* Manually expire an item in the cache.
* @param key The key to expire.
*/
expireItem(key: string): Promise<void>;
}

/**
* 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<string, { value: string; expiresAt?: Date }> = new Map();
getItem(key: string): Promise<string | undefined> {
// eslint-disable-next-line @typescript-eslint/no-explicit-any
#values: Map<string, { value: any; expiresAt?: Date }> = new Map();
getItem<T>(key: string): Promise<T | undefined> {
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<void> {
setItem<T>(key: string, value: T, expiresInSeconds: number): Promise<void> {
const expiresAt = new Date(new Date().getTime() + expiresInSeconds * 1000);
this.#values.set(key, {
value,
Expand Down