Skip to content

Commit

Permalink
Add basic statistics endpoint (#69)
Browse files Browse the repository at this point in the history
* #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)
  • Loading branch information
danielemery authored Nov 6, 2023
1 parent 3a71c74 commit a5e7c4a
Show file tree
Hide file tree
Showing 12 changed files with 259 additions and 10 deletions.
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

0 comments on commit a5e7c4a

Please sign in to comment.