-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
* #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
- Loading branch information
1 parent
91849e7
commit bbfcccc
Showing
15 changed files
with
689 additions
and
0 deletions.
There are no files selected for viewing
Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.
Oops, something went wrong.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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<string, never>, 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<string, never>, _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); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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: '[email protected]' }, | ||
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: '[email protected]' }], | ||
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: '[email protected]' }, | ||
type: 'BRAINWAVES', | ||
}, | ||
{ | ||
id: 'fake-quiz-id-two', | ||
date: new Date('2020-08-08'), | ||
uploadedAt: new Date('2021-01-11'), | ||
uploadedBy: { name: 'Tracey', email: '[email protected]' }, | ||
type: 'SHARK', | ||
}, | ||
{ | ||
id: 'fake-quiz-id-three', | ||
date: new Date('2020-10-11'), | ||
uploadedAt: new Date('2021-01-02'), | ||
uploadedBy: { name: 'Grant', email: '[email protected]' }, | ||
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: '[email protected]' }], | ||
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: '[email protected]' }])).toEqual('John'); | ||
expect(sut.userListToString([{ email: '[email protected]' }])).toEqual('[email protected]'); | ||
}); | ||
it('must return names separated by & for two users', () => { | ||
expect( | ||
sut.userListToString([ | ||
{ name: 'Jack', email: '[email protected]' }, | ||
{ name: 'Jill', email: '[email protected]' }, | ||
]), | ||
).toEqual('Jack & Jill'); | ||
}); | ||
it('must return names separated by commas and & for three or more users', () => { | ||
expect( | ||
sut.userListToString([ | ||
{ name: 'Jack', email: '[email protected]' }, | ||
{ name: 'Jill', email: '[email protected]' }, | ||
{ name: 'Bob', email: '[email protected]' }, | ||
{ email: '[email protected]' }, | ||
]), | ||
).toEqual('Jack, Jill, Bob & [email protected]'); | ||
}); | ||
}); | ||
}); | ||
}); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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]}`; | ||
} | ||
} |
Oops, something went wrong.