From 15fa3cf08d68b1190a24dbf4c167aea7d45dfcb0 Mon Sep 17 00:00:00 2001 From: Jason Ng Date: Wed, 12 Mar 2025 03:02:18 -0400 Subject: [PATCH] [PM-10613] End User Notification Service (#13721) * new end user notification service to retrieve and update notifications from API --- .../src/services/jslib-services.module.ts | 7 + .../src/platform/state/state-definitions.ts | 1 + libs/common/src/types/guid.ts | 1 + libs/vault/src/index.ts | 1 + .../end-user-notification.service.ts | 49 +++++ libs/vault/src/notifications/index.ts | 2 + libs/vault/src/notifications/models/index.ts | 3 + .../models/notification-view.data.ts | 37 ++++ .../models/notification-view.response.ts | 23 +++ .../notifications/models/notification-view.ts | 21 ++ ...ault-end-user-notification.service.spec.ts | 193 ++++++++++++++++++ .../default-end-user-notification.service.ts | 104 ++++++++++ .../state/end-user-notification.state.ts | 15 ++ 13 files changed, 457 insertions(+) create mode 100644 libs/vault/src/notifications/abstractions/end-user-notification.service.ts create mode 100644 libs/vault/src/notifications/index.ts create mode 100644 libs/vault/src/notifications/models/index.ts create mode 100644 libs/vault/src/notifications/models/notification-view.data.ts create mode 100644 libs/vault/src/notifications/models/notification-view.response.ts create mode 100644 libs/vault/src/notifications/models/notification-view.ts create mode 100644 libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts create mode 100644 libs/vault/src/notifications/services/default-end-user-notification.service.ts create mode 100644 libs/vault/src/notifications/state/end-user-notification.state.ts diff --git a/libs/angular/src/services/jslib-services.module.ts b/libs/angular/src/services/jslib-services.module.ts index 0069dd3e30f..d31cd0c84e2 100644 --- a/libs/angular/src/services/jslib-services.module.ts +++ b/libs/angular/src/services/jslib-services.module.ts @@ -304,6 +304,8 @@ import { import { SafeInjectionToken } from "@bitwarden/ui-common"; import { DefaultTaskService, + DefaultEndUserNotificationService, + EndUserNotificationService, NewDeviceVerificationNoticeService, PasswordRepromptService, TaskService, @@ -1465,6 +1467,11 @@ const safeProviders: SafeProvider[] = [ useClass: DefaultTaskService, deps: [StateProvider, ApiServiceAbstraction, OrganizationServiceAbstraction, ConfigService], }), + safeProvider({ + provide: EndUserNotificationService, + useClass: DefaultEndUserNotificationService, + deps: [StateProvider, ApiServiceAbstraction], + }), safeProvider({ provide: DeviceTrustToastServiceAbstraction, useClass: DeviceTrustToastService, diff --git a/libs/common/src/platform/state/state-definitions.ts b/libs/common/src/platform/state/state-definitions.ts index 58fb3f18250..82cb8bb1e37 100644 --- a/libs/common/src/platform/state/state-definitions.ts +++ b/libs/common/src/platform/state/state-definitions.ts @@ -201,3 +201,4 @@ export const NEW_DEVICE_VERIFICATION_NOTICE = new StateDefinition( export const VAULT_APPEARANCE = new StateDefinition("vaultAppearance", "disk"); export const SECURITY_TASKS_DISK = new StateDefinition("securityTasks", "disk"); export const AT_RISK_PASSWORDS_PAGE_DISK = new StateDefinition("atRiskPasswordsPage", "disk"); +export const NOTIFICATION_DISK = new StateDefinition("notifications", "disk"); diff --git a/libs/common/src/types/guid.ts b/libs/common/src/types/guid.ts index 5ad498c115a..7a0ec4c1d58 100644 --- a/libs/common/src/types/guid.ts +++ b/libs/common/src/types/guid.ts @@ -11,3 +11,4 @@ export type CipherId = Opaque; export type SendId = Opaque; export type IndexedEntityId = Opaque; export type SecurityTaskId = Opaque; +export type NotificationId = Opaque; diff --git a/libs/vault/src/index.ts b/libs/vault/src/index.ts index d21e430f0a3..f359b7289ae 100644 --- a/libs/vault/src/index.ts +++ b/libs/vault/src/index.ts @@ -26,6 +26,7 @@ export * from "./components/carousel"; export * as VaultIcons from "./icons"; export * from "./tasks"; +export * from "./notifications"; export { DefaultSshImportPromptService } from "./services/default-ssh-import-prompt.service"; export { SshImportPromptService } from "./services/ssh-import-prompt.service"; diff --git a/libs/vault/src/notifications/abstractions/end-user-notification.service.ts b/libs/vault/src/notifications/abstractions/end-user-notification.service.ts new file mode 100644 index 00000000000..2ed7e1de631 --- /dev/null +++ b/libs/vault/src/notifications/abstractions/end-user-notification.service.ts @@ -0,0 +1,49 @@ +import { Observable } from "rxjs"; + +import { UserId } from "@bitwarden/common/types/guid"; + +import { NotificationView } from "../models"; + +/** + * A service for retrieving and managing notifications for end users. + */ +export abstract class EndUserNotificationService { + /** + * Observable of all notifications for the given user. + * @param userId + */ + abstract notifications$(userId: UserId): Observable; + + /** + * Observable of all unread notifications for the given user. + * @param userId + */ + abstract unreadNotifications$(userId: UserId): Observable; + + /** + * Mark a notification as read. + * @param notificationId + * @param userId + */ + abstract markAsRead(notificationId: any, userId: UserId): Promise; + + /** + * Mark a notification as deleted. + * @param notificationId + * @param userId + */ + abstract markAsDeleted(notificationId: any, userId: UserId): Promise; + + /** + * Create/update a notification in the state for the user specified within the notification. + * @remarks This method should only be called when a notification payload is received from the web socket. + * @param notification + */ + abstract upsert(notification: Notification): Promise; + + /** + * Clear all notifications from state for the given user. + * @param userId + */ + abstract clearState(userId: UserId): Promise; +} diff --git a/libs/vault/src/notifications/index.ts b/libs/vault/src/notifications/index.ts new file mode 100644 index 00000000000..0c9d5c0d16b --- /dev/null +++ b/libs/vault/src/notifications/index.ts @@ -0,0 +1,2 @@ +export * from "./abstractions/end-user-notification.service"; +export * from "./services/default-end-user-notification.service"; diff --git a/libs/vault/src/notifications/models/index.ts b/libs/vault/src/notifications/models/index.ts new file mode 100644 index 00000000000..b782335caa9 --- /dev/null +++ b/libs/vault/src/notifications/models/index.ts @@ -0,0 +1,3 @@ +export * from "./notification-view"; +export * from "./notification-view.data"; +export * from "./notification-view.response"; diff --git a/libs/vault/src/notifications/models/notification-view.data.ts b/libs/vault/src/notifications/models/notification-view.data.ts new file mode 100644 index 00000000000..07c147052ad --- /dev/null +++ b/libs/vault/src/notifications/models/notification-view.data.ts @@ -0,0 +1,37 @@ +import { Jsonify } from "type-fest"; + +import { NotificationId } from "@bitwarden/common/types/guid"; + +import { NotificationViewResponse } from "./notification-view.response"; + +export class NotificationViewData { + id: NotificationId; + priority: number; + title: string; + body: string; + date: Date; + readDate: Date | null; + deletedDate: Date | null; + + constructor(response: NotificationViewResponse) { + this.id = response.id; + this.priority = response.priority; + this.title = response.title; + this.body = response.body; + this.date = response.date; + this.readDate = response.readDate; + this.deletedDate = response.deletedDate; + } + + static fromJSON(obj: Jsonify) { + return Object.assign(new NotificationViewData({} as NotificationViewResponse), obj, { + id: obj.id, + priority: obj.priority, + title: obj.title, + body: obj.body, + date: new Date(obj.date), + readDate: obj.readDate ? new Date(obj.readDate) : null, + deletedDate: obj.deletedDate ? new Date(obj.deletedDate) : null, + }); + } +} diff --git a/libs/vault/src/notifications/models/notification-view.response.ts b/libs/vault/src/notifications/models/notification-view.response.ts new file mode 100644 index 00000000000..bbebf25bd4e --- /dev/null +++ b/libs/vault/src/notifications/models/notification-view.response.ts @@ -0,0 +1,23 @@ +import { BaseResponse } from "@bitwarden/common/models/response/base.response"; +import { NotificationId } from "@bitwarden/common/types/guid"; + +export class NotificationViewResponse extends BaseResponse { + id: NotificationId; + priority: number; + title: string; + body: string; + date: Date; + readDate: Date; + deletedDate: Date; + + constructor(response: any) { + super(response); + this.id = this.getResponseProperty("Id"); + this.priority = this.getResponseProperty("Priority"); + this.title = this.getResponseProperty("Title"); + this.body = this.getResponseProperty("Body"); + this.date = this.getResponseProperty("Date"); + this.readDate = this.getResponseProperty("ReadDate"); + this.deletedDate = this.getResponseProperty("DeletedDate"); + } +} diff --git a/libs/vault/src/notifications/models/notification-view.ts b/libs/vault/src/notifications/models/notification-view.ts new file mode 100644 index 00000000000..b577a889d05 --- /dev/null +++ b/libs/vault/src/notifications/models/notification-view.ts @@ -0,0 +1,21 @@ +import { NotificationId } from "@bitwarden/common/types/guid"; + +export class NotificationView { + id: NotificationId; + priority: number; + title: string; + body: string; + date: Date; + readDate: Date | null; + deletedDate: Date | null; + + constructor(obj: any) { + this.id = obj.id; + this.priority = obj.priority; + this.title = obj.title; + this.body = obj.body; + this.date = obj.date; + this.readDate = obj.readDate; + this.deletedDate = obj.deletedDate; + } +} diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts new file mode 100644 index 00000000000..ac4304998bc --- /dev/null +++ b/libs/vault/src/notifications/services/default-end-user-notification.service.spec.ts @@ -0,0 +1,193 @@ +import { TestBed } from "@angular/core/testing"; +import { firstValueFrom } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { NotificationId, UserId } from "@bitwarden/common/types/guid"; +import { DefaultEndUserNotificationService } from "@bitwarden/vault"; + +import { FakeStateProvider, mockAccountServiceWith } from "../../../../common/spec"; +import { NotificationViewResponse } from "../models"; +import { NOTIFICATIONS } from "../state/end-user-notification.state"; + +describe("End User Notification Center Service", () => { + let fakeStateProvider: FakeStateProvider; + + const mockApiSend = jest.fn(); + + let testBed: TestBed; + + beforeEach(async () => { + mockApiSend.mockClear(); + + fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId)); + + testBed = TestBed.configureTestingModule({ + imports: [], + providers: [ + DefaultEndUserNotificationService, + { + provide: StateProvider, + useValue: fakeStateProvider, + }, + { + provide: ApiService, + useValue: { + send: mockApiSend, + }, + }, + ], + }); + }); + + describe("notifications$", () => { + it("should return notifications from state when not null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + } as NotificationViewResponse, + ]); + + const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const result = await firstValueFrom(notifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + + it("should return notifications API when state is null", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, null as any); + + const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const result = await firstValueFrom(notifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true); + }); + + it("should share the same observable for the same user", async () => { + const { notifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const first = notifications$("user-id" as UserId); + const second = notifications$("user-id" as UserId); + + expect(first).toBe(second); + }); + }); + + describe("unreadNotifications$", () => { + it("should return unread notifications from state when read value is null", async () => { + fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + readDate: null as any, + } as NotificationViewResponse, + ]); + + const { unreadNotifications$ } = testBed.inject(DefaultEndUserNotificationService); + + const result = await firstValueFrom(unreadNotifications$("user-id" as UserId)); + + expect(result.length).toBe(1); + expect(mockApiSend).not.toHaveBeenCalled(); + }); + }); + + describe("getNotifications", () => { + it("should call getNotifications returning notifications from API", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.getNotifications("user-id" as UserId); + + expect(mockApiSend).toHaveBeenCalledWith("GET", "/notifications", null, true, true); + }); + }); + it("should update local state when notifications are updated", async () => { + mockApiSend.mockResolvedValue({ + data: [ + { + id: "notification-id", + }, + ] as NotificationViewResponse[], + }); + + const mock = fakeStateProvider.singleUser.mockFor( + "user-id" as UserId, + NOTIFICATIONS, + null as any, + ); + + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.getNotifications("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([ + expect.objectContaining({ + id: "notification-id" as NotificationId, + } as NotificationViewResponse), + ]); + }); + + describe("clear", () => { + it("should clear the local notification state for the user", async () => { + const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, NOTIFICATIONS, [ + { + id: "notification-id" as NotificationId, + } as NotificationViewResponse, + ]); + + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.clearState("user-id" as UserId); + + expect(mock.nextMock).toHaveBeenCalledWith([]); + }); + }); + + describe("markAsDeleted", () => { + it("should send an API request to mark the notification as deleted", async () => { + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.markAsDeleted("notification-id" as NotificationId, "user-id" as UserId); + expect(mockApiSend).toHaveBeenCalledWith( + "DELETE", + "/notifications/notification-id/delete", + null, + true, + false, + ); + }); + }); + + describe("markAsRead", () => { + it("should send an API request to mark the notification as read", async () => { + const service = testBed.inject(DefaultEndUserNotificationService); + + await service.markAsRead("notification-id" as NotificationId, "user-id" as UserId); + expect(mockApiSend).toHaveBeenCalledWith( + "PATCH", + "/notifications/notification-id/read", + null, + true, + false, + ); + }); + }); +}); diff --git a/libs/vault/src/notifications/services/default-end-user-notification.service.ts b/libs/vault/src/notifications/services/default-end-user-notification.service.ts new file mode 100644 index 00000000000..517a968f8af --- /dev/null +++ b/libs/vault/src/notifications/services/default-end-user-notification.service.ts @@ -0,0 +1,104 @@ +import { Injectable } from "@angular/core"; +import { map, Observable, switchMap } from "rxjs"; + +import { ApiService } from "@bitwarden/common/abstractions/api.service"; +import { ListResponse } from "@bitwarden/common/models/response/list.response"; +import { StateProvider } from "@bitwarden/common/platform/state"; +import { UserId } from "@bitwarden/common/types/guid"; + +import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities"; +import { EndUserNotificationService } from "../abstractions/end-user-notification.service"; +import { NotificationView, NotificationViewData, NotificationViewResponse } from "../models"; +import { NOTIFICATIONS } from "../state/end-user-notification.state"; + +/** + * A service for retrieving and managing notifications for end users. + */ +@Injectable() +export class DefaultEndUserNotificationService implements EndUserNotificationService { + constructor( + private stateProvider: StateProvider, + private apiService: ApiService, + ) {} + + notifications$ = perUserCache$((userId: UserId): Observable => { + return this.notificationState(userId).state$.pipe( + switchMap(async (notifications) => { + if (notifications == null) { + await this.fetchNotificationsFromApi(userId); + } + return notifications; + }), + filterOutNullish(), + map((notifications) => + notifications.map((notification) => new NotificationView(notification)), + ), + ); + }); + + unreadNotifications$ = perUserCache$((userId: UserId): Observable => { + return this.notifications$(userId).pipe( + map((notifications) => notifications.filter((notification) => notification.readDate == null)), + ); + }); + + async markAsRead(notificationId: any, userId: UserId): Promise { + await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false); + await this.getNotifications(userId); + } + + async markAsDeleted(notificationId: any, userId: UserId): Promise { + await this.apiService.send( + "DELETE", + `/notifications/${notificationId}/delete`, + null, + true, + false, + ); + await this.getNotifications(userId); + } + + upsert(notification: Notification): any {} + + async clearState(userId: UserId): Promise { + await this.updateNotificationState(userId, []); + } + + async getNotifications(userId: UserId) { + await this.fetchNotificationsFromApi(userId); + } + + /** + * Fetches the notifications from the API and updates the local state + * @param userId + * @private + */ + private async fetchNotificationsFromApi(userId: UserId): Promise { + const res = await this.apiService.send("GET", "/notifications", null, true, true); + const response = new ListResponse(res, NotificationViewResponse); + const notificationData = response.data.map((n) => new NotificationView(n)); + await this.updateNotificationState(userId, notificationData); + } + + /** + * Updates the local state with notifications and returns the updated state + * @param userId + * @param notifications + * @private + */ + private updateNotificationState( + userId: UserId, + notifications: NotificationViewData[], + ): Promise { + return this.notificationState(userId).update(() => notifications); + } + + /** + * Returns the local state for notifications + * @param userId + * @private + */ + private notificationState(userId: UserId) { + return this.stateProvider.getUser(userId, NOTIFICATIONS); + } +} diff --git a/libs/vault/src/notifications/state/end-user-notification.state.ts b/libs/vault/src/notifications/state/end-user-notification.state.ts new file mode 100644 index 00000000000..644c8e42429 --- /dev/null +++ b/libs/vault/src/notifications/state/end-user-notification.state.ts @@ -0,0 +1,15 @@ +import { Jsonify } from "type-fest"; + +import { NOTIFICATION_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state"; + +import { NotificationViewData } from "../models"; + +export const NOTIFICATIONS = UserKeyDefinition.array( + NOTIFICATION_DISK, + "notifications", + { + deserializer: (notification: Jsonify) => + NotificationViewData.fromJSON(notification), + clearOn: ["logout", "lock"], + }, +);