From bbfcccc97424f7a032fe97ceaf5f6216ec637eb0 Mon Sep 17 00:00:00 2001 From: Daniel Emery Date: Sun, 21 Jul 2024 23:39:45 +0200 Subject: [PATCH] Activity feed resolver (#82) * #81 Add new quiz service function to get recent quiz completions * #81 Modify recent quiz completions to be consistent with what we want to return for recent uploads * #81 Add new quiz service function to get recently uploaded quizzes * #81 Add utility function to get a string-formatted list of users * #81 Add new getRecentActivity function to the activity service * #81 Expose new recent activity feature via graphql * #81 Handle some edge cases in user persistence where if undefined was passed an arbitrary user was resolved * #81 Add new functions to get users from the activity they participated in * #81 Add remaining cases for getUsersForActivity * #81 Split the loading of activities from the related users * #81 User data loader to optimize the recent activity feed * #81 Improve activity feed text --- package-lock.json | 11 ++ package.json | 1 + src/activity/activity.gql.ts | 31 +++++ src/activity/activity.service.test.ts | 176 ++++++++++++++++++++++++++ src/activity/activity.service.ts | 96 ++++++++++++++ src/gql.ts | 34 +++++ src/index.ts | 3 + src/quiz/quiz.persistence.ts | 45 +++++++ src/quiz/quiz.service.test.ts | 76 +++++++++++ src/quiz/quiz.service.ts | 36 ++++++ src/service.locator.ts | 4 + src/user/user.persistence.ts | 78 ++++++++++++ src/user/user.service.test.ts | 48 +++++++ src/user/user.service.ts | 49 +++++++ src/util/common.errors.ts | 1 + 15 files changed, 689 insertions(+) create mode 100644 src/activity/activity.gql.ts create mode 100644 src/activity/activity.service.test.ts create mode 100644 src/activity/activity.service.ts create mode 100644 src/util/common.errors.ts diff --git a/package-lock.json b/package-lock.json index bf9ef14..19eb83a 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,6 +18,7 @@ "axios": "^1.2.6", "body-parser": "^1.20.2", "cors": "^2.8.5", + "dataloader": "^2.2.2", "express": "^4.19.2", "graphql": "^16.5.0", "graphql-tag": "^2.12.6", @@ -5688,6 +5689,11 @@ "node": ">= 8" } }, + "node_modules/dataloader": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", + "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==" + }, "node_modules/debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", @@ -14417,6 +14423,11 @@ "which": "^2.0.1" } }, + "dataloader": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/dataloader/-/dataloader-2.2.2.tgz", + "integrity": "sha512-8YnDaaf7N3k/q5HnTJVuzSyLETjoZjVmHc4AeKAzOvKHEFQKcn64OKBfzHYtE9zGjctNM7V9I0MfnUVLpi7M5g==" + }, "debug": { "version": "4.3.4", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz", diff --git a/package.json b/package.json index 6d76cf7..b4b584d 100644 --- a/package.json +++ b/package.json @@ -38,6 +38,7 @@ "axios": "^1.2.6", "body-parser": "^1.20.2", "cors": "^2.8.5", + "dataloader": "^2.2.2", "express": "^4.19.2", "graphql": "^16.5.0", "graphql-tag": "^2.12.6", diff --git a/src/activity/activity.gql.ts b/src/activity/activity.gql.ts new file mode 100644 index 0000000..a3a2150 --- /dev/null +++ b/src/activity/activity.gql.ts @@ -0,0 +1,31 @@ +import DataLoader from 'dataloader'; +import { QuizlordContext } from '..'; +import { authorisationService, activityService, userService } from '../service.locator'; +import { RecentActivityItem } from './activity.service'; + +async function activityFeed(_: unknown, _params: Record, context: QuizlordContext) { + authorisationService.requireUserRole(context, 'USER'); + + return activityService.getRecentActivity(); +} + +// eslint-disable-next-line @typescript-eslint/no-unused-vars +async function activityFeedUser(parent: RecentActivityItem, _params: Record, _context: QuizlordContext) { + return usersForActivityLoader.load(parent); +} + +export const activityQueries = { + activityFeed, +}; + +export const activityChildren = { + users: activityFeedUser, +}; + +async function batchUsersForActivities(activityItems: readonly RecentActivityItem[]) { + const users = await userService.getUsersForActivities(activityItems); + return activityItems.map((activityItem) => users[activityItem.resourceId] || []); +} + +// Create DataLoader instance +export const usersForActivityLoader = new DataLoader(batchUsersForActivities); diff --git a/src/activity/activity.service.test.ts b/src/activity/activity.service.test.ts new file mode 100644 index 0000000..7507378 --- /dev/null +++ b/src/activity/activity.service.test.ts @@ -0,0 +1,176 @@ +import { QuizService } from '../quiz/quiz.service'; +import { UnhandledError } from '../util/common.errors'; +import { ActivityService } from './activity.service'; + +const mockQuizService = { + getRecentQuizUploads: jest.fn(), + getRecentQuizCompletions: jest.fn(), +}; + +const sut = new ActivityService(mockQuizService as unknown as QuizService); +describe('activity', () => { + describe('activity.service', () => { + describe('getRecentActivity', () => { + it('must work when there is only an upload', async () => { + mockQuizService.getRecentQuizUploads.mockResolvedValueOnce([ + { + id: 'fake-quiz-id', + date: new Date('2020-06-07'), + uploadedAt: new Date('2021-01-01'), + uploadedBy: { name: 'Grant', email: 'grant@quizlord.net' }, + type: 'SHARK', + }, + ]); + mockQuizService.getRecentQuizCompletions.mockResolvedValueOnce([]); + + const actual = await sut.getRecentActivity(5); + + expect(actual).toEqual([ + { + date: new Date('2021-01-01'), + actionType: 'QUIZ_UPLOADED', + resourceId: 'fake-quiz-id', + text: 'Uploaded a SHARK from June 7, 2020', + }, + ]); + }); + it('must work when there is only a completion', async () => { + mockQuizService.getRecentQuizUploads.mockResolvedValueOnce([]); + mockQuizService.getRecentQuizCompletions.mockResolvedValueOnce([ + { + id: 'fake-completion-id', + quizDate: new Date('2020-03-23'), + quizType: 'SHARK', + score: 12, + completedBy: [{ name: 'Master' }, { name: 'Beginner' }, { email: 'jack@quizlord.net' }], + completionDate: new Date('2021-01-01'), + }, + ]); + + const actual = await sut.getRecentActivity(5); + + expect(actual).toEqual([ + { + date: new Date('2021-01-01'), + actionType: 'QUIZ_COMPLETED', + resourceId: 'fake-completion-id', + text: 'Scored 12 on the SHARK from March 23, 2020', + }, + ]); + }); + it('must call quizService.getRecentQuizUploads and quizService.getRecentQuizCompletions and combine the results', async () => { + mockQuizService.getRecentQuizUploads.mockResolvedValueOnce([ + { + id: 'fake-quiz-id-one', + date: new Date('2020-06-07'), + uploadedAt: new Date('2021-01-21'), + uploadedBy: { name: 'Bob', email: 'bob@quizlord.net' }, + type: 'BRAINWAVES', + }, + { + id: 'fake-quiz-id-two', + date: new Date('2020-08-08'), + uploadedAt: new Date('2021-01-11'), + uploadedBy: { name: 'Tracey', email: 'tracey@quizlord.net' }, + type: 'SHARK', + }, + { + id: 'fake-quiz-id-three', + date: new Date('2020-10-11'), + uploadedAt: new Date('2021-01-02'), + uploadedBy: { name: 'Grant', email: 'grant@quizlord.net' }, + type: 'SHARK', + }, + ]); + mockQuizService.getRecentQuizCompletions.mockResolvedValueOnce([ + { + id: 'fake-completion-id-one', + quizDate: new Date('2020-03-23'), + quizType: 'BRAINWAVES', + score: 19, + completedBy: [{ name: 'Chloe' }], + completionDate: new Date('2021-01-31'), + }, + { + id: 'fake-completion-id-two', + quizDate: new Date('2020-03-23'), + quizType: 'SHARK', + score: 12, + completedBy: [{ name: 'Daniel' }], + completionDate: new Date('2021-01-05'), + }, + { + id: 'fake-completion-id-three', + quizDate: new Date('2020-03-23'), + quizType: 'SHARK', + score: 9, + completedBy: [{ name: 'Master' }, { name: 'Beginner' }, { email: 'jack@quizlord.net' }], + completionDate: new Date('2021-01-01'), + }, + ]); + + const actual = await sut.getRecentActivity(5); + + expect(actual).toEqual([ + { + date: new Date('2021-01-31'), + actionType: 'QUIZ_COMPLETED', + resourceId: 'fake-completion-id-one', + text: 'Scored 19 on the BRAINWAVES from March 23, 2020', + }, + { + date: new Date('2021-01-21'), + actionType: 'QUIZ_UPLOADED', + resourceId: 'fake-quiz-id-one', + text: 'Uploaded a BRAINWAVES from June 7, 2020', + }, + { + date: new Date('2021-01-11'), + actionType: 'QUIZ_UPLOADED', + resourceId: 'fake-quiz-id-two', + text: 'Uploaded a SHARK from August 8, 2020', + }, + { + date: new Date('2021-01-05'), + actionType: 'QUIZ_COMPLETED', + resourceId: 'fake-completion-id-two', + text: 'Scored 12 on the SHARK from March 23, 2020', + }, + { + date: new Date('2021-01-02'), + actionType: 'QUIZ_UPLOADED', + resourceId: 'fake-quiz-id-three', + text: 'Uploaded a SHARK from October 11, 2020', + }, + ]); + }); + }); + describe('userListToString', () => { + it('must throw an error if the user list is empty', () => { + expect(() => sut.userListToString([])).toThrow(UnhandledError); + }); + it('must just return the name or email of a single user', () => { + expect(sut.userListToString([{ name: 'John', email: 'john@quizlord.net' }])).toEqual('John'); + expect(sut.userListToString([{ email: 'john@quizlord.net' }])).toEqual('john@quizlord.net'); + }); + it('must return names separated by & for two users', () => { + expect( + sut.userListToString([ + { name: 'Jack', email: 'jack@quizlord.net' }, + { name: 'Jill', email: 'jill@quizlord.net' }, + ]), + ).toEqual('Jack & Jill'); + }); + it('must return names separated by commas and & for three or more users', () => { + expect( + sut.userListToString([ + { name: 'Jack', email: 'jack@quizlord.net' }, + { name: 'Jill', email: 'jill@quizlord.net' }, + { name: 'Bob', email: 'bob@quizlord.net' }, + { email: 'nabbs@quizlord.net' }, + ]), + ).toEqual('Jack, Jill, Bob & nabbs@quizlord.net'); + }); + }); + }); +}); diff --git a/src/activity/activity.service.ts b/src/activity/activity.service.ts new file mode 100644 index 0000000..8dd9a11 --- /dev/null +++ b/src/activity/activity.service.ts @@ -0,0 +1,96 @@ +import { QuizService } from '../quiz/quiz.service'; +import { UnhandledError } from '../util/common.errors'; + +// TODO later generate this from the gql schema. +export interface RecentActivityItem { + date: Date; + actionType: 'QUIZ_COMPLETED' | 'QUIZ_UPLOADED'; + resourceId: string; + text: string; + action?: { + name: string; + link: string; + }; +} + +const quizDateFormatter = new Intl.DateTimeFormat('en', { + month: 'long', + day: 'numeric', + year: 'numeric', +}); + +export class ActivityService { + #quizService: QuizService; + constructor(quizService: QuizService) { + this.#quizService = quizService; + } + + /** + * Get the most recent `first` activity items. + * + * These currently include quiz uploads and completions. + * + * @param first The number of activity items to return. + * @returns The most recent `first` activity items. + */ + async getRecentActivity(first = 20) { + const [recentUploads, recentCompletions] = await Promise.all([ + this.#quizService.getRecentQuizUploads(first), + this.#quizService.getRecentQuizCompletions(first), + ]); + + const results: RecentActivityItem[] = []; + + let uploadIndex = 0; + let completionIndex = 0; + const end = Math.min(first, recentUploads.length + recentCompletions.length); + while (uploadIndex + completionIndex < end) { + const upload = recentUploads[uploadIndex]; + const completion = recentCompletions[completionIndex]; + + if (!completion || (upload && upload.uploadedAt > completion.completionDate)) { + results.push({ + date: upload.uploadedAt, + actionType: 'QUIZ_UPLOADED', + resourceId: upload.id, + text: `Uploaded a ${upload.type} from ${quizDateFormatter.format(upload.date)}`, + }); + uploadIndex++; + } else { + results.push({ + date: completion.completionDate, + actionType: 'QUIZ_COMPLETED', + resourceId: completion.id, + text: `Scored ${completion.score} on the ${completion.quizType} from ${quizDateFormatter.format( + completion.quizDate, + )}`, + }); + completionIndex++; + } + } + + return results; + } + /** + * Get a formatted string list of users. + * @param users List of userlike objects (contain and email and optionally a name) + * @returns A formatted string list of users. + * + * // TODO move this to utils or to user service. + */ + userListToString( + users: { + name?: string | null; + email: string; + }[], + ) { + if (users.length === 0) { + throw new UnhandledError('Cannot format an empty user list'); + } + const names = users.map((user) => user.name ?? user.email); + if (names.length === 1) { + return names[0]; + } + return `${names.slice(0, -1).join(', ')} & ${names[names.length - 1]}`; + } +} diff --git a/src/gql.ts b/src/gql.ts index fe8122b..68f0069 100644 --- a/src/gql.ts +++ b/src/gql.ts @@ -35,6 +35,11 @@ const typeDefs = gql` AVERAGE_SCORE_DESC } + enum ActivityActionType { + QUIZ_COMPLETED + QUIZ_UPLOADED + } + type PageInfo { hasNextPage: Boolean startCursor: String @@ -139,6 +144,30 @@ const typeDefs = gql` ANYONE } + "Optional action that can be taken when the activity is clicked" + type RecentActivityAction { + "Name of the action to take when the activity is clicked" + name: String! + "Link to the url to navigate to when the activity is clicked" + link: String! + } + + "An item in the recent activity feed" + type RecentActivityItem { + "The date the activity occurred" + date: Date! + "The type of activity that occurred" + actionType: ActivityActionType! + "The id of the resource that the activity relates to" + resourceId: String! + "The text to display for the activity" + text: String! + "The user who performed the activity" + users: [User]! + "Optional action to take when the activity is clicked" + action: RecentActivityAction + } + "Available filters for the quizzes query" input QuizFilters { """ @@ -181,6 +210,11 @@ const typeDefs = gql` "The sorting option to use" sortedBy: IndividualUserStatisticsSortOption ): [IndividualUserStatistic] + + """ + Get the most recent activities. + """ + activityFeed: [RecentActivityItem] } type Mutation { diff --git a/src/index.ts b/src/index.ts index 942e19d..7d17e1f 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,7 @@ import { authenticationService, prismaService, queueService, userService } from import config from './config/config'; import typeDefs from './gql'; import { userQueries } from './user/user.gql'; +import { activityQueries, activityChildren } from './activity/activity.gql'; import { quizMutations, quizQueries } from './quiz/quiz.gql'; import { Role } from './user/user.dto'; import { statisticsQueries } from './statistics/statistics.gql'; @@ -42,10 +43,12 @@ const resolvers = { ...quizQueries, ...userQueries, ...statisticsQueries, + ...activityQueries, }, Mutation: { ...quizMutations, }, + RecentActivityItem: activityChildren, }; export interface QuizlordContext { diff --git a/src/quiz/quiz.persistence.ts b/src/quiz/quiz.persistence.ts index d090637..1f6b3b5 100644 --- a/src/quiz/quiz.persistence.ts +++ b/src/quiz/quiz.persistence.ts @@ -256,4 +256,49 @@ export class QuizPersistence { }, }); } + + async getRecentQuizCompletions({ limit }: { limit: number }) { + return this.#prisma.client().quizCompletion.findMany({ + take: limit, + orderBy: { + completedAt: 'desc', + }, + include: { + completedBy: { + select: { + user: { + select: { + email: true, + name: true, + }, + }, + }, + }, + quiz: { + select: { + id: true, + date: true, + type: true, + }, + }, + }, + }); + } + + getRecentQuizUploads({ limit }: { limit: number }) { + return this.#prisma.client().quiz.findMany({ + take: limit, + orderBy: { + uploadedAt: 'desc', + }, + include: { + uploadedByUser: { + select: { + email: true, + name: true, + }, + }, + }, + }); + } } diff --git a/src/quiz/quiz.service.test.ts b/src/quiz/quiz.service.test.ts index 84f2c82..aaa9517 100644 --- a/src/quiz/quiz.service.test.ts +++ b/src/quiz/quiz.service.test.ts @@ -15,6 +15,8 @@ const mockPersistence = { getCompletionScoreWithQuizTypesForUser: jest.fn(), getQuizzesWithUserResults: jest.fn(), getQuizByIdWithResults: jest.fn(), + getRecentQuizCompletions: jest.fn(), + getRecentQuizUploads: jest.fn(), }; const mockFileService = { createKey: jest.fn(), @@ -315,5 +317,79 @@ describe('quiz', () => { }); }); }); + describe('getRecentQuizCompletions', () => { + it('must call getRecentQuizCompletions on persistence with correct arguments and transform the result', async () => { + mockPersistence.getRecentQuizCompletions.mockResolvedValueOnce([ + { + id: 'fake-completion-id', + completedAt: new Date('2023-01-01'), + score: new Decimal(12), + completedBy: [ + { + user: { + email: 'master@quizlord.net', + name: 'Quiz Master', + }, + }, + ], + quiz: { + id: 'fake-quiz-id', + type: 'SHARK', + date: new Date('2022-12-12'), + }, + }, + ]); + + const actual = await sut.getRecentQuizCompletions(); + + expect(mockPersistence.getRecentQuizCompletions).toHaveBeenCalledTimes(1); + expect(mockPersistence.getRecentQuizCompletions).toHaveBeenCalledWith({ limit: 20 }); + + expect(actual).toEqual([ + { + id: 'fake-completion-id', + completionDate: new Date('2023-01-01'), + score: 12, + quizId: 'fake-quiz-id', + quizDate: new Date('2022-12-12'), + quizType: 'SHARK', + }, + ]); + }); + }); + describe('getRecentQuizUploads', () => { + it('must call getRecentQuizUploads on persistence with correct arguments and transform the result', async () => { + mockPersistence.getRecentQuizUploads.mockResolvedValueOnce([ + { + id: 'fake-quiz-id', + type: 'SHARK', + date: new Date('2022-12-12'), + uploadedAt: new Date('2023-01-01'), + uploadedByUser: { + email: 'master@quizlord.net', + name: 'Quiz Master', + }, + }, + ]); + + const actual = await sut.getRecentQuizUploads(); + + expect(mockPersistence.getRecentQuizUploads).toHaveBeenCalledTimes(1); + expect(mockPersistence.getRecentQuizUploads).toHaveBeenCalledWith({ limit: 20 }); + + expect(actual).toEqual([ + { + id: 'fake-quiz-id', + type: 'SHARK', + date: new Date('2022-12-12'), + uploadedAt: new Date('2023-01-01'), + uploadedBy: { + email: 'master@quizlord.net', + name: 'Quiz Master', + }, + }, + ]); + }); + }); }); }); diff --git a/src/quiz/quiz.service.ts b/src/quiz/quiz.service.ts index e7154c8..efbe090 100644 --- a/src/quiz/quiz.service.ts +++ b/src/quiz/quiz.service.ts @@ -213,6 +213,42 @@ export class QuizService { }; } + /** + * Get the most recent `first` quiz completions along with their participants. + * @param first The number of completions to get. Defaults to 20. + * @returns The most recent `first` quiz completions along with their participants. + */ + async getRecentQuizCompletions(first = 20) { + const recent = await this.#persistence.getRecentQuizCompletions({ limit: first }); + return recent.map((completion) => ({ + id: completion.id, + quizId: completion.quiz.id, + quizType: completion.quiz.type, + quizDate: completion.quiz.date, + completionDate: completion.completedAt, + score: completion.score.toNumber(), + })); + } + + /** + * Get the most recent `first` quiz uploads. + * @param first The number of quizzes to get. Defaults to 20. + * @returns The most recent `first` quiz uploads. + */ + async getRecentQuizUploads(first = 20) { + const recent = await this.#persistence.getRecentQuizUploads({ limit: first }); + return recent.map((quiz) => ({ + id: quiz.id, + type: quiz.type, + date: quiz.date, + uploadedAt: quiz.uploadedAt, + uploadedBy: { + name: quiz.uploadedByUser.name, + email: quiz.uploadedByUser.email, + }, + })); + } + 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 f52e3db..67065e1 100644 --- a/src/service.locator.ts +++ b/src/service.locator.ts @@ -1,3 +1,4 @@ +import { ActivityService } from './activity/activity.service'; import { AuthenticationService } from './auth/authentication.service'; import { AuthorisationService } from './auth/authorisation.service'; import { PrismaService } from './database/prisma.service'; @@ -37,3 +38,6 @@ export const queueService = new SQSQueueService(quizService); // statistics export const statisticsService = new StatisticsService(userService, quizService, memoryCache); + +// activity +export const activityService = new ActivityService(quizService); diff --git a/src/user/user.persistence.ts b/src/user/user.persistence.ts index 1a48c6c..fb1910d 100644 --- a/src/user/user.persistence.ts +++ b/src/user/user.persistence.ts @@ -3,6 +3,7 @@ import { Role, User } from '@prisma/client'; import { PrismaService } from '../database/prisma.service'; import { UserSortOption } from '../user/user.dto'; import { getPagedQuery, slicePagedResults } from '../util/paging-helpers'; +import { RecentActivityItem } from '../activity/activity.service'; export interface GetUsersWithRoleResult { data: { @@ -20,6 +21,7 @@ export class UserPersistence { } async getUserByEmail(email: string) { + if (!email) return null; return this.#prisma.client().user.findFirst({ include: { roles: {}, @@ -31,6 +33,7 @@ export class UserPersistence { } async getUserById(id: string) { + if (!id) return null; return this.#prisma.client().user.findFirst({ where: { id, @@ -174,4 +177,79 @@ export class UserPersistence { `) as User[]; return slicePagedResults(result, limit, afterId !== undefined); } + + /** + * Get all the users that participated in the quiz completion with the given id. + * @param quizCompletionId The id of the quiz completion to get users for. + * @returns The users that participated in the quiz completion with the given id. + */ + getUsersForQuizCompletion(quizCompletionId: string) { + return this.#prisma.client().user.findMany({ + where: { + quizCompletions: { + some: { + quizCompletionId: quizCompletionId, + }, + }, + }, + }); + } + + /** + * Get the user that uploaded the quiz with the given id. + * @param quizId The id of the quiz to get the upload user for. + * @returns The user that uploaded the quiz with the given id. + */ + getUserForQuizUpload(quizId: string) { + return this.#prisma.client().user.findFirst({ + where: { + uploadedQuizzes: { + some: { + id: quizId, + }, + }, + }, + }); + } + + async getUsersForQuizUploads(quizUploadActivityItems: RecentActivityItem[]): Promise> { + const result = await this.#prisma.client().quiz.findMany({ + where: { + id: { + in: quizUploadActivityItems.map((item) => item.resourceId), + }, + }, + include: { + uploadedByUser: true, + }, + }); + return result.reduce>((acc, quiz) => { + acc[quiz.id] = [quiz.uploadedByUser]; + return acc; + }, {}); + } + + async getUsersForQuizCompletions(quizCompletionActivityItems: RecentActivityItem[]): Promise> { + const result = await this.#prisma.client().quizCompletion.findMany({ + where: { + id: { + in: quizCompletionActivityItems.map((item) => item.resourceId), + }, + }, + include: { + completedBy: { + include: { + user: true, + }, + }, + }, + }); + return result.reduce>((acc, quizCompletion) => { + const users = quizCompletion.completedBy.map((cb) => cb.user); + return { + ...acc, + [quizCompletion.id]: users, + }; + }, {}); + } } diff --git a/src/user/user.service.test.ts b/src/user/user.service.test.ts index d61b26a..1a2492c 100644 --- a/src/user/user.service.test.ts +++ b/src/user/user.service.test.ts @@ -12,6 +12,8 @@ const fakeUserPersistence = { getUserByEmail: jest.fn(), getUserById: jest.fn(), updateUserName: jest.fn(), + getUsersForQuizCompletion: jest.fn(), + getUserForQuizUpload: jest.fn(), }; describe('user', () => { @@ -125,5 +127,51 @@ describe('user', () => { }); }); }); + describe('getUsersForActivity', () => { + it('must return empty list for unknown action type', async () => { + const actual = await sut.getUsersForActivity({ + actionType: 'UNKNOWN' as 'QUIZ_UPLOADED', + date: new Date(), + resourceId: 'fake-id', + text: 'fake-text', + }); + + expect(actual).toEqual([]); + }); + it('must return users for quiz completion', async () => { + const expected = [{ id: 'fake-user-id', email: 'fake-email@domain.com', name: 'fake-name' }]; + + fakeUserPersistence.getUsersForQuizCompletion.mockResolvedValueOnce(expected); + + const actual = await sut.getUsersForActivity({ + actionType: 'QUIZ_COMPLETED', + date: new Date(), + resourceId: 'fake-completion-id', + text: 'fake-text', + }); + + expect(fakeUserPersistence.getUsersForQuizCompletion).toHaveBeenCalledTimes(1); + expect(fakeUserPersistence.getUsersForQuizCompletion).toHaveBeenCalledWith('fake-completion-id'); + + expect(actual).toEqual(expected); + }); + it('must return user for quiz upload', async () => { + const fakeUser = { id: 'fake-user-id', email: 'fake-email@domain.com', name: 'fake-name' }; + + fakeUserPersistence.getUserForQuizUpload.mockResolvedValueOnce(fakeUser); + + const actual = await sut.getUsersForActivity({ + actionType: 'QUIZ_UPLOADED', + date: new Date(), + resourceId: 'fake-quiz-id', + text: 'fake-text', + }); + + expect(fakeUserPersistence.getUserForQuizUpload).toHaveBeenCalledTimes(1); + expect(fakeUserPersistence.getUserForQuizUpload).toHaveBeenCalledWith('fake-quiz-id'); + + expect(actual).toEqual([fakeUser]); + }); + }); }); }); diff --git a/src/user/user.service.ts b/src/user/user.service.ts index 468e366..33e40e2 100644 --- a/src/user/user.service.ts +++ b/src/user/user.service.ts @@ -4,6 +4,7 @@ import { User as UserPersistenceModel, Role as RolePersistenceModel } from '@pri import { Role, User, UserSortOption } from './user.dto'; import { UserPersistence } from './user.persistence'; import { UserNotFoundError } from './user.errors'; +import { RecentActivityItem } from '../activity/activity.service'; export interface GetUsersResult { data: User[]; @@ -129,4 +130,52 @@ export class UserService { name: user.name ?? undefined, }; } + + /** + * Get all users that participated in the given activity. + * @param parent The activity item to get users for. + */ + async getUsersForActivity(parent: RecentActivityItem) { + switch (parent.actionType) { + case 'QUIZ_COMPLETED': + return this.#persistence.getUsersForQuizCompletion(parent.resourceId); + case 'QUIZ_UPLOADED': { + const uploadUser = await this.#persistence.getUserForQuizUpload(parent.resourceId); + return [uploadUser]; + } + default: + return []; + } + } + + /** + * Get all the users that participated in the quiz completions and quiz uploads of the given activity items. + * @param activityItems The activity items to get users for. + * @returns A map from the activity id to the users that participated in the activity. + */ + async getUsersForActivities(activityItems: readonly RecentActivityItem[]): Promise> { + const quizCompletionActivityItems = activityItems.filter( + (activityItem) => activityItem.actionType === 'QUIZ_COMPLETED', + ); + const quizCompletionUserPersistenceMap = + await this.#persistence.getUsersForQuizCompletions(quizCompletionActivityItems); + const quizCompletionUserMap = Object.fromEntries( + Object.entries(quizCompletionUserPersistenceMap).map(([quizCompletionId, users]) => [ + quizCompletionId, + users.map((user) => this.#userPersistenceToUser(user)), + ]), + ); + const quizUploadActivityItems = activityItems.filter((activityItem) => activityItem.actionType === 'QUIZ_UPLOADED'); + const quizUploadUserPersistenceMap = await this.#persistence.getUsersForQuizUploads(quizUploadActivityItems); + const quizUploadUserMap = Object.fromEntries( + Object.entries(quizUploadUserPersistenceMap).map(([quizId, user]) => [ + quizId, + user.map((user) => this.#userPersistenceToUser(user)), + ]), + ); + return { + ...quizCompletionUserMap, + ...quizUploadUserMap, + }; + } } diff --git a/src/util/common.errors.ts b/src/util/common.errors.ts new file mode 100644 index 0000000..7e49072 --- /dev/null +++ b/src/util/common.errors.ts @@ -0,0 +1 @@ +export class UnhandledError extends Error {}