Skip to content

Commit

Permalink
Activity feed resolver (#82)
Browse files Browse the repository at this point in the history
* #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
danielemery authored Jul 21, 2024
1 parent 91849e7 commit bbfcccc
Show file tree
Hide file tree
Showing 15 changed files with 689 additions and 0 deletions.
11 changes: 11 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
31 changes: 31 additions & 0 deletions src/activity/activity.gql.ts
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);
176 changes: 176 additions & 0 deletions src/activity/activity.service.test.ts
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]');
});
});
});
});
96 changes: 96 additions & 0 deletions src/activity/activity.service.ts
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]}`;
}
}
Loading

0 comments on commit bbfcccc

Please sign in to comment.