feat(NotificationService): endpoint to manually recalculate notification overview

This commit is contained in:
Pujit Mehrotra
2024-10-02 10:56:35 -04:00
parent 46aa3a3e24
commit 1e2f57a4cd
5 changed files with 107 additions and 15 deletions

View File

@@ -643,6 +643,8 @@ export type Mutation = {
/** Pause parity check */
pauseParityCheck?: Maybe<Scalars['JSON']['output']>;
reboot?: Maybe<Scalars['String']['output']>;
/** Reads each notification to recompute & update the overview. */
recalculateOverview: NotificationOverview;
/** Remove existing disk from array. NOTE: The array must be stopped before running this otherwise it'll throw an error. */
removeDiskFromArray?: Maybe<ArrayType>;
/** Resume parity check */
@@ -864,6 +866,7 @@ export type Notifications = Node & {
__typename?: 'Notifications';
id: Scalars['ID']['output'];
list: Array<Notification>;
/** A cached overview of the notifications in the system & their severity. */
overview: NotificationOverview;
};
@@ -2359,6 +2362,7 @@ export type MutationResolvers<ContextType = Context, ParentType extends Resolver
mountArrayDisk?: Resolver<Maybe<ResolversTypes['Disk']>, ParentType, ContextType, RequireFields<MutationmountArrayDiskArgs, 'id'>>;
pauseParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
reboot?: Resolver<Maybe<ResolversTypes['String']>, ParentType, ContextType>;
recalculateOverview?: Resolver<ResolversTypes['NotificationOverview'], ParentType, ContextType>;
removeDiskFromArray?: Resolver<Maybe<ResolversTypes['Array']>, ParentType, ContextType, Partial<MutationremoveDiskFromArrayArgs>>;
resumeParityCheck?: Resolver<Maybe<ResolversTypes['JSON']>, ParentType, ContextType>;
setAdditionalAllowedOrigins?: Resolver<Array<ResolversTypes['String']>, ParentType, ContextType, RequireFields<MutationsetAdditionalAllowedOriginsArgs, 'input'>>;

View File

@@ -25,6 +25,8 @@ type Mutation {
unarchiveNotifications(ids: [String!]): NotificationOverview!
archiveAll(importance: Importance): NotificationOverview!
unarchiveAll(importance: Importance): NotificationOverview!
"""Reads each notification to recompute & update the overview."""
recalculateOverview: NotificationOverview!
}
type Subscription {
@@ -40,6 +42,7 @@ enum Importance {
type Notifications implements Node {
id: ID!
"""A cached overview of the notifications in the system & their severity."""
overview: NotificationOverview!
list(filter: NotificationFilter!): [Notification!]!
}

View File

@@ -9,6 +9,7 @@ import { UseRoles } from 'nest-access-control';
import { createSubscription, PUBSUB_CHANNEL } from '@app/core/pubsub';
import { NotificationsService } from './notifications.service';
import { Importance } from '@app/graphql/generated/client/graphql';
import { AppError } from '@app/core/errors/app-error';
@Resolver('Notifications')
export class NotificationsResolver {
@@ -101,6 +102,15 @@ export class NotificationsResolver {
return overview;
}
@Mutation()
public async recalculateOverview() {
const { overview, error } = await this.notificationsService.recalculateOverview();
if (error) {
throw new AppError("Failed to refresh overview", 500);
}
return overview;
}
/**============================================
* Subscriptions
*=============================================**/

View File

@@ -1,6 +1,6 @@
import { Test, TestingModule } from '@nestjs/testing';
import { NotificationsService } from './notifications.service';
import { describe, it, expect, vi, beforeAll, afterEach } from 'vitest';
import { describe, it, expect, vi, beforeAll, afterEach, assert } from 'vitest';
import { existsSync } from 'fs';
import {
Importance,
@@ -216,6 +216,11 @@ describe.sequential('NotificationsService', () => {
};
const notification = await createNotification(notificationData);
// HACK: we brute-force re-calculate instead of using service.getOverview()
// because the file-system-watcher's test setup isn't working rn.
let { overview } = await service.recalculateOverview();
expect.soft(overview.unread.total).toEqual(1);
// data in returned notification (from createNotification) matches?
Object.entries(notificationData).forEach(([key, value]) => {
expect(notification[key]).toEqual(value);
@@ -231,12 +236,13 @@ describe.sequential('NotificationsService', () => {
expect(storedNotification[key]).toEqual(value);
});
expect(service.getOverview().unread.total).toEqual(1);
// notification was deleted
await service.deleteNotification({ id: notification.id, type: NotificationType.UNREAD });
const deleted = await findById(notification.id);
expect(deleted).toBeUndefined();
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(0);
});
it.each(notificationImportance)('loadNotifications respects %s filter', async (importance) => {
@@ -251,7 +257,9 @@ describe.sequential('NotificationsService', () => {
createNotification({ importance: Importance.WARNING }),
createNotification({ importance: Importance.WARNING }),
]);
const { overview } = await service.recalculateOverview();
expect(notifications.length).toEqual(9);
expect.soft(overview.unread.total).toEqual(9);
// don't use the `expectIn` helper, just in case it changes
const loaded = await service.getNotifications({
@@ -267,18 +275,29 @@ describe.sequential('NotificationsService', () => {
* CRUD: Update Tests
*---------------------------------------------**/
it('can correctly archive and unarchive a notification', async ({ expect }) => {
await forEachImportance(async (importance) => {
it.for(notificationImportance.map((i) => [i]))(
'can correctly archive and unarchive a %s notification',
async ([importance], { expect }) => {
const notification = await createNotification({ importance });
let { overview } = await service.recalculateOverview();
expect.soft(overview.unread.total).toEqual(1);
expect.soft(overview.archive.total).toEqual(0);
await service.archiveNotification(notification);
let exists = await doesExist(expect)(notification, NotificationType.ARCHIVE);
if (!exists) return;
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(0);
expect.soft(overview.archive.total).toEqual(1);
await service.markAsUnread(notification);
exists = await doesExist(expect)(notification, NotificationType.UNREAD);
});
});
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(1);
expect.soft(overview.archive.total).toEqual(0);
}
);
it.each(notificationImportance)('can archiveAll & unarchiveAll %s', async (importance) => {
const expectIn = makeExpectIn(expect);
@@ -293,19 +312,35 @@ describe.sequential('NotificationsService', () => {
createNotification({ importance: Importance.WARNING }),
createNotification({ importance: Importance.WARNING }),
]);
expect(notifications.length).toEqual(9);
await expectIn({ type: NotificationType.UNREAD }, 9);
let { overview } = await service.recalculateOverview();
expect.soft(overview.unread.total).toEqual(9);
expect.soft(overview.archive.total).toEqual(0);
await service.archiveAll();
await expectIn({ type: NotificationType.ARCHIVE }, 9);
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(0);
expect.soft(overview.archive.total).toEqual(9);
await service.unarchiveAll();
await expectIn({ type: NotificationType.UNREAD }, 9);
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(9);
expect.soft(overview.archive.total).toEqual(0);
await service.archiveAll(importance);
await expectIn({ type: NotificationType.ARCHIVE }, 3);
await expectIn({ type: NotificationType.UNREAD }, 6);
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(6);
expect.soft(overview.archive.total).toEqual(3);
// archive another importance set, just to make sure unarchiveAll
// isn't just ignoring the filter, which would be possible if it only
// contained the stuff it was supposed to unarchive.
@@ -315,16 +350,16 @@ describe.sequential('NotificationsService', () => {
await expectIn({ type: NotificationType.ARCHIVE }, 6);
await expectIn({ type: NotificationType.UNREAD }, 3);
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(3);
expect.soft(overview.archive.total).toEqual(6);
await service.unarchiveAll(importance);
await expectIn({ type: NotificationType.ARCHIVE }, 3);
await expectIn({ type: NotificationType.UNREAD }, 6);
});
/**========================================================================
* Overview (Notification Stats) State
*========================================================================**/
it.skip('calculates stats correctly', async () => {
// todo implement
({ overview } = await service.recalculateOverview());
expect.soft(overview.unread.total).toEqual(6);
expect.soft(overview.archive.total).toEqual(3);
});
});

View File

@@ -132,6 +132,46 @@ export class NotificationsService {
collector['total'] -= 1;
}
public async recalculateOverview() {
const overview: NotificationOverview = {
unread: {
alert: 0,
info: 0,
warning: 0,
total: 0,
},
archive: {
alert: 0,
info: 0,
warning: 0,
total: 0,
},
};
// todo - refactor this to be more memory efficient
// i.e. by using a lazy generator vs the current eager implementation
//
// recalculates stats for a particular notification type
const recalculate = async (type: NotificationType) => {
const ids = await this.listFilesInFolder(this.paths()[type]);
const [notifications] = await this.loadNotificationsFromPaths(ids, {});
notifications.forEach((n) => this.increment(n.importance, overview[type.toLowerCase()]));
};
const results = await batchProcess(
[NotificationType.ARCHIVE, NotificationType.UNREAD],
recalculate
);
if (results.errorOccured) {
results.errors.forEach((e) => this.logger.error('[recalculateOverview] ' + e));
}
NotificationsService.overview = overview;
void this.publishOverview();
return { error: results.errorOccured, overview: this.getOverview() };
}
/**------------------------------------------------------------------------
* CRUD: Creating Notifications
*------------------------------------------------------------------------**/
@@ -538,7 +578,7 @@ export class NotificationsService {
type: 'ini',
});
this.logger.debug(`Loaded notification ini file from ${path}}`);
this.logger.verbose(`Loaded notification ini file from ${path}}`);
const notification: Notification = this.notificationFileToGqlNotification(
{ id: this.getIdFromPath(path), type },