1
0
mirror of https://github.com/bitwarden/browser synced 2025-12-06 00:13:28 +00:00

[PM-10613] End User Notification Service (#13721)

* new end user notification service to retrieve and update notifications from API
This commit is contained in:
Jason Ng
2025-03-12 03:02:18 -04:00
committed by GitHub
parent b988993a88
commit 15fa3cf08d
13 changed files with 457 additions and 0 deletions

View File

@@ -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,

View File

@@ -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");

View File

@@ -11,3 +11,4 @@ export type CipherId = Opaque<string, "CipherId">;
export type SendId = Opaque<string, "SendId">;
export type IndexedEntityId = Opaque<string, "IndexedEntityId">;
export type SecurityTaskId = Opaque<string, "SecurityTaskId">;
export type NotificationId = Opaque<string, "NotificationId">;

View File

@@ -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";

View File

@@ -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<NotificationView[]>;
/**
* Observable of all unread notifications for the given user.
* @param userId
*/
abstract unreadNotifications$(userId: UserId): Observable<NotificationView[]>;
/**
* Mark a notification as read.
* @param notificationId
* @param userId
*/
abstract markAsRead(notificationId: any, userId: UserId): Promise<void>;
/**
* Mark a notification as deleted.
* @param notificationId
* @param userId
*/
abstract markAsDeleted(notificationId: any, userId: UserId): Promise<void>;
/**
* 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<void>;
/**
* Clear all notifications from state for the given user.
* @param userId
*/
abstract clearState(userId: UserId): Promise<void>;
}

View File

@@ -0,0 +1,2 @@
export * from "./abstractions/end-user-notification.service";
export * from "./services/default-end-user-notification.service";

View File

@@ -0,0 +1,3 @@
export * from "./notification-view";
export * from "./notification-view.data";
export * from "./notification-view.response";

View File

@@ -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<NotificationViewData>) {
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,
});
}
}

View File

@@ -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");
}
}

View File

@@ -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;
}
}

View File

@@ -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,
);
});
});
});

View File

@@ -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<NotificationView[]> => {
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<NotificationView[]> => {
return this.notifications$(userId).pipe(
map((notifications) => notifications.filter((notification) => notification.readDate == null)),
);
});
async markAsRead(notificationId: any, userId: UserId): Promise<void> {
await this.apiService.send("PATCH", `/notifications/${notificationId}/read`, null, true, false);
await this.getNotifications(userId);
}
async markAsDeleted(notificationId: any, userId: UserId): Promise<void> {
await this.apiService.send(
"DELETE",
`/notifications/${notificationId}/delete`,
null,
true,
false,
);
await this.getNotifications(userId);
}
upsert(notification: Notification): any {}
async clearState(userId: UserId): Promise<void> {
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<void> {
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<NotificationViewData[] | null> {
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);
}
}

View File

@@ -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<NotificationViewData>(
NOTIFICATION_DISK,
"notifications",
{
deserializer: (notification: Jsonify<NotificationViewData>) =>
NotificationViewData.fromJSON(notification),
clearOn: ["logout", "lock"],
},
);