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:
@@ -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,
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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">;
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
2
libs/vault/src/notifications/index.ts
Normal file
2
libs/vault/src/notifications/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./abstractions/end-user-notification.service";
|
||||
export * from "./services/default-end-user-notification.service";
|
||||
3
libs/vault/src/notifications/models/index.ts
Normal file
3
libs/vault/src/notifications/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./notification-view";
|
||||
export * from "./notification-view.data";
|
||||
export * from "./notification-view.response";
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
21
libs/vault/src/notifications/models/notification-view.ts
Normal file
21
libs/vault/src/notifications/models/notification-view.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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"],
|
||||
},
|
||||
);
|
||||
Reference in New Issue
Block a user