mirror of
https://github.com/bitwarden/browser
synced 2025-12-15 15:53:27 +00:00
[PM-17563] [PM-19754] Migrate Security Task Module to libs/common (#14036)
* [PM-17563] Remove references to Angular from TaskService * [PM-17563] Move Task module to libs/common/vault to avoid Angular dependency * [PM-17563] Fix bad imports * [PM-17563] Fix a few more missed imports
This commit is contained in:
46
libs/common/src/vault/tasks/abstractions/task.service.ts
Normal file
46
libs/common/src/vault/tasks/abstractions/task.service.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
import { Observable } from "rxjs";
|
||||
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SecurityTask } from "../models";
|
||||
|
||||
export abstract class TaskService {
|
||||
/**
|
||||
* Observable indicating if tasks are enabled for a given user.
|
||||
*
|
||||
* @remarks Internally, this checks the user's organization details to determine if tasks are enabled.
|
||||
* @param userId
|
||||
*/
|
||||
abstract tasksEnabled$(userId: UserId): Observable<boolean>;
|
||||
|
||||
/**
|
||||
* Observable of all tasks for a given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract tasks$(userId: UserId): Observable<SecurityTask[]>;
|
||||
|
||||
/**
|
||||
* Observable of pending tasks for a given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract pendingTasks$(userId: UserId): Observable<SecurityTask[]>;
|
||||
|
||||
/**
|
||||
* Retrieves tasks from the API for a given user and updates the local state.
|
||||
* @param userId
|
||||
*/
|
||||
abstract refreshTasks(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Clears all the tasks from state for the given user.
|
||||
* @param userId
|
||||
*/
|
||||
abstract clear(userId: UserId): Promise<void>;
|
||||
|
||||
/**
|
||||
* Marks a task as complete in local state and updates the server.
|
||||
* @param taskId - The ID of the task to mark as complete.
|
||||
* @param userId - The user who is completing the task.
|
||||
*/
|
||||
abstract markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void>;
|
||||
}
|
||||
2
libs/common/src/vault/tasks/enums/index.ts
Normal file
2
libs/common/src/vault/tasks/enums/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from "./security-task-status.enum";
|
||||
export * from "./security-task-type.enum";
|
||||
@@ -0,0 +1,11 @@
|
||||
export enum SecurityTaskStatus {
|
||||
/**
|
||||
* Default status for newly created tasks that have not been completed.
|
||||
*/
|
||||
Pending = 0,
|
||||
|
||||
/**
|
||||
* Status when a task is considered complete and has no remaining actions
|
||||
*/
|
||||
Completed = 1,
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export enum SecurityTaskType {
|
||||
/**
|
||||
* Task to update a cipher's password that was found to be at-risk by an administrator
|
||||
*/
|
||||
UpdateAtRiskCredential = 0,
|
||||
}
|
||||
5
libs/common/src/vault/tasks/index.ts
Normal file
5
libs/common/src/vault/tasks/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from "./enums";
|
||||
export * from "./models";
|
||||
|
||||
export * from "./abstractions/task.service";
|
||||
export * from "./services/default-task.service";
|
||||
3
libs/common/src/vault/tasks/models/index.ts
Normal file
3
libs/common/src/vault/tasks/models/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from "./security-task";
|
||||
export * from "./security-task.data";
|
||||
export * from "./security-task.response";
|
||||
34
libs/common/src/vault/tasks/models/security-task.data.ts
Normal file
34
libs/common/src/vault/tasks/models/security-task.data.ts
Normal file
@@ -0,0 +1,34 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SecurityTaskStatus, SecurityTaskType } from "../enums";
|
||||
|
||||
import { SecurityTaskResponse } from "./security-task.response";
|
||||
|
||||
export class SecurityTaskData {
|
||||
id: SecurityTaskId;
|
||||
organizationId: OrganizationId;
|
||||
cipherId: CipherId | undefined;
|
||||
type: SecurityTaskType;
|
||||
status: SecurityTaskStatus;
|
||||
creationDate: Date;
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(response: SecurityTaskResponse) {
|
||||
this.id = response.id;
|
||||
this.organizationId = response.organizationId;
|
||||
this.cipherId = response.cipherId;
|
||||
this.type = response.type;
|
||||
this.status = response.status;
|
||||
this.creationDate = response.creationDate;
|
||||
this.revisionDate = response.revisionDate;
|
||||
}
|
||||
|
||||
static fromJSON(obj: Jsonify<SecurityTaskData>) {
|
||||
return Object.assign(new SecurityTaskData({} as SecurityTaskResponse), obj, {
|
||||
creationDate: new Date(obj.creationDate),
|
||||
revisionDate: new Date(obj.revisionDate),
|
||||
});
|
||||
}
|
||||
}
|
||||
28
libs/common/src/vault/tasks/models/security-task.response.ts
Normal file
28
libs/common/src/vault/tasks/models/security-task.response.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { BaseResponse } from "@bitwarden/common/models/response/base.response";
|
||||
import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SecurityTaskStatus, SecurityTaskType } from "../enums";
|
||||
|
||||
export class SecurityTaskResponse extends BaseResponse {
|
||||
id: SecurityTaskId;
|
||||
organizationId: OrganizationId;
|
||||
/**
|
||||
* Optional cipherId for tasks that are related to a cipher.
|
||||
*/
|
||||
cipherId: CipherId | undefined;
|
||||
type: SecurityTaskType;
|
||||
status: SecurityTaskStatus;
|
||||
creationDate: Date;
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(response: any) {
|
||||
super(response);
|
||||
this.id = this.getResponseProperty("Id");
|
||||
this.organizationId = this.getResponseProperty("OrganizationId");
|
||||
this.cipherId = this.getResponseProperty("CipherId") || undefined;
|
||||
this.type = this.getResponseProperty("Type");
|
||||
this.status = this.getResponseProperty("Status");
|
||||
this.creationDate = this.getResponseProperty("CreationDate");
|
||||
this.revisionDate = this.getResponseProperty("RevisionDate");
|
||||
}
|
||||
}
|
||||
28
libs/common/src/vault/tasks/models/security-task.ts
Normal file
28
libs/common/src/vault/tasks/models/security-task.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
import { CipherId, OrganizationId, SecurityTaskId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { SecurityTaskStatus, SecurityTaskType } from "../enums";
|
||||
|
||||
import { SecurityTaskData } from "./security-task.data";
|
||||
|
||||
export class SecurityTask {
|
||||
id: SecurityTaskId;
|
||||
organizationId: OrganizationId;
|
||||
/**
|
||||
* Optional cipherId for tasks that are related to a cipher.
|
||||
*/
|
||||
cipherId: CipherId | undefined;
|
||||
type: SecurityTaskType;
|
||||
status: SecurityTaskStatus;
|
||||
creationDate: Date;
|
||||
revisionDate: Date;
|
||||
|
||||
constructor(obj: SecurityTaskData) {
|
||||
this.id = obj.id;
|
||||
this.organizationId = obj.organizationId;
|
||||
this.cipherId = obj.cipherId;
|
||||
this.type = obj.type;
|
||||
this.status = obj.status;
|
||||
this.creationDate = obj.creationDate;
|
||||
this.revisionDate = obj.revisionDate;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,260 @@
|
||||
import { BehaviorSubject, firstValueFrom } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { Organization } from "@bitwarden/common/admin-console/models/domain/organization";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { FakeStateProvider, mockAccountServiceWith } from "../../../../spec";
|
||||
import { SecurityTaskStatus } from "../enums";
|
||||
import { SecurityTaskData, SecurityTaskResponse } from "../models";
|
||||
import { SECURITY_TASKS } from "../state/security-task.state";
|
||||
|
||||
import { DefaultTaskService } from "./default-task.service";
|
||||
|
||||
describe("Default task service", () => {
|
||||
let fakeStateProvider: FakeStateProvider;
|
||||
|
||||
const mockApiSend = jest.fn();
|
||||
const mockGetAllOrgs$ = jest.fn();
|
||||
const mockGetFeatureFlag$ = jest.fn();
|
||||
|
||||
let service: DefaultTaskService;
|
||||
|
||||
beforeEach(async () => {
|
||||
mockApiSend.mockClear();
|
||||
mockGetAllOrgs$.mockClear();
|
||||
mockGetFeatureFlag$.mockClear();
|
||||
|
||||
fakeStateProvider = new FakeStateProvider(mockAccountServiceWith("user-id" as UserId));
|
||||
service = new DefaultTaskService(
|
||||
fakeStateProvider,
|
||||
{ send: mockApiSend } as unknown as ApiService,
|
||||
{ organizations$: mockGetAllOrgs$ } as unknown as OrganizationService,
|
||||
{ getFeatureFlag$: mockGetFeatureFlag$ } as unknown as ConfigService,
|
||||
);
|
||||
});
|
||||
|
||||
describe("tasksEnabled$", () => {
|
||||
it("should emit true if any organization uses risk insights", async () => {
|
||||
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
},
|
||||
{
|
||||
useRiskInsights: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
|
||||
const { tasksEnabled$ } = service;
|
||||
|
||||
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
|
||||
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it("should emit false if no organization uses risk insights", async () => {
|
||||
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(true));
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: false,
|
||||
},
|
||||
{
|
||||
useRiskInsights: false,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
|
||||
const { tasksEnabled$ } = service;
|
||||
|
||||
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it("should emit false if the feature flag is off", async () => {
|
||||
mockGetFeatureFlag$.mockReturnValue(new BehaviorSubject(false));
|
||||
mockGetAllOrgs$.mockReturnValue(
|
||||
new BehaviorSubject([
|
||||
{
|
||||
useRiskInsights: true,
|
||||
},
|
||||
] as Organization[]),
|
||||
);
|
||||
|
||||
const { tasksEnabled$ } = service;
|
||||
|
||||
const result = await firstValueFrom(tasksEnabled$("user-id" as UserId));
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("tasks$", () => {
|
||||
it("should fetch tasks from the API when the state is null", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "task-id",
|
||||
},
|
||||
] as SecurityTaskResponse[],
|
||||
});
|
||||
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, null as any);
|
||||
|
||||
const { tasks$ } = service;
|
||||
|
||||
const result = await firstValueFrom(tasks$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
|
||||
});
|
||||
|
||||
it("should use the tasks from state when not null", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
|
||||
{
|
||||
id: "task-id" as SecurityTaskId,
|
||||
} as SecurityTaskData,
|
||||
]);
|
||||
|
||||
const { tasks$ } = service;
|
||||
|
||||
const result = await firstValueFrom(tasks$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(mockApiSend).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should share the same observable for the same user", async () => {
|
||||
const { tasks$ } = service;
|
||||
|
||||
const first = tasks$("user-id" as UserId);
|
||||
const second = tasks$("user-id" as UserId);
|
||||
|
||||
expect(first).toBe(second);
|
||||
});
|
||||
});
|
||||
|
||||
describe("pendingTasks$", () => {
|
||||
it("should filter tasks to only pending tasks", async () => {
|
||||
fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
|
||||
{
|
||||
id: "completed-task-id" as SecurityTaskId,
|
||||
status: SecurityTaskStatus.Completed,
|
||||
},
|
||||
{
|
||||
id: "pending-task-id" as SecurityTaskId,
|
||||
status: SecurityTaskStatus.Pending,
|
||||
},
|
||||
] as SecurityTaskData[]);
|
||||
|
||||
const { pendingTasks$ } = service;
|
||||
|
||||
const result = await firstValueFrom(pendingTasks$("user-id" as UserId));
|
||||
|
||||
expect(result.length).toBe(1);
|
||||
expect(result[0].id).toBe("pending-task-id" as SecurityTaskId);
|
||||
});
|
||||
});
|
||||
|
||||
describe("refreshTasks()", () => {
|
||||
it("should fetch tasks from the API", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "task-id",
|
||||
},
|
||||
] as SecurityTaskResponse[],
|
||||
});
|
||||
|
||||
await service.refreshTasks("user-id" as UserId);
|
||||
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
|
||||
});
|
||||
|
||||
it("should update the local state with refreshed tasks", async () => {
|
||||
mockApiSend.mockResolvedValue({
|
||||
data: [
|
||||
{
|
||||
id: "task-id",
|
||||
},
|
||||
] as SecurityTaskResponse[],
|
||||
});
|
||||
|
||||
const mock = fakeStateProvider.singleUser.mockFor(
|
||||
"user-id" as UserId,
|
||||
SECURITY_TASKS,
|
||||
null as any,
|
||||
);
|
||||
|
||||
await service.refreshTasks("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([
|
||||
{
|
||||
id: "task-id" as SecurityTaskId,
|
||||
} as SecurityTaskData,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("clear()", () => {
|
||||
it("should clear the local state for the user", async () => {
|
||||
const mock = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
|
||||
{
|
||||
id: "task-id" as SecurityTaskId,
|
||||
} as SecurityTaskData,
|
||||
]);
|
||||
|
||||
await service.clear("user-id" as UserId);
|
||||
|
||||
expect(mock.nextMock).toHaveBeenCalledWith([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("markAsComplete()", () => {
|
||||
it("should send an API request to mark the task as complete", async () => {
|
||||
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
|
||||
|
||||
expect(mockApiSend).toHaveBeenCalledWith(
|
||||
"PATCH",
|
||||
"/tasks/task-id/complete",
|
||||
null,
|
||||
true,
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
it("should refresh all tasks for the user after marking the task as complete", async () => {
|
||||
mockApiSend
|
||||
.mockResolvedValueOnce(null) // Mark as complete
|
||||
.mockResolvedValueOnce({
|
||||
// Refresh tasks
|
||||
data: [
|
||||
{
|
||||
id: "new-task-id",
|
||||
},
|
||||
] as SecurityTaskResponse[],
|
||||
});
|
||||
|
||||
const mockState = fakeStateProvider.singleUser.mockFor("user-id" as UserId, SECURITY_TASKS, [
|
||||
{
|
||||
id: "old-task-id" as SecurityTaskId,
|
||||
} as SecurityTaskData,
|
||||
]);
|
||||
|
||||
await service.markAsComplete("task-id" as SecurityTaskId, "user-id" as UserId);
|
||||
|
||||
expect(mockApiSend).toHaveBeenCalledWith("GET", "/tasks", null, true, true);
|
||||
expect(mockState.nextMock).toHaveBeenCalledWith([
|
||||
{
|
||||
id: "new-task-id",
|
||||
} as SecurityTaskData,
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
100
libs/common/src/vault/tasks/services/default-task.service.ts
Normal file
100
libs/common/src/vault/tasks/services/default-task.service.ts
Normal file
@@ -0,0 +1,100 @@
|
||||
import { combineLatest, map, switchMap } from "rxjs";
|
||||
|
||||
import { ApiService } from "@bitwarden/common/abstractions/api.service";
|
||||
import { OrganizationService } from "@bitwarden/common/admin-console/abstractions/organization/organization.service.abstraction";
|
||||
import { FeatureFlag } from "@bitwarden/common/enums/feature-flag.enum";
|
||||
import { ListResponse } from "@bitwarden/common/models/response/list.response";
|
||||
import { ConfigService } from "@bitwarden/common/platform/abstractions/config/config.service";
|
||||
import { StateProvider } from "@bitwarden/common/platform/state";
|
||||
import { SecurityTaskId, UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
import { filterOutNullish, perUserCache$ } from "../../utils/observable-utilities";
|
||||
import { TaskService } from "../abstractions/task.service";
|
||||
import { SecurityTaskStatus } from "../enums";
|
||||
import { SecurityTask, SecurityTaskData, SecurityTaskResponse } from "../models";
|
||||
import { SECURITY_TASKS } from "../state/security-task.state";
|
||||
|
||||
export class DefaultTaskService implements TaskService {
|
||||
constructor(
|
||||
private stateProvider: StateProvider,
|
||||
private apiService: ApiService,
|
||||
private organizationService: OrganizationService,
|
||||
private configService: ConfigService,
|
||||
) {}
|
||||
|
||||
tasksEnabled$ = perUserCache$((userId) => {
|
||||
return combineLatest([
|
||||
this.organizationService
|
||||
.organizations$(userId)
|
||||
.pipe(map((orgs) => orgs.some((o) => o.useRiskInsights))),
|
||||
this.configService.getFeatureFlag$(FeatureFlag.SecurityTasks),
|
||||
]).pipe(map(([atLeastOneOrgEnabled, flagEnabled]) => atLeastOneOrgEnabled && flagEnabled));
|
||||
});
|
||||
|
||||
tasks$ = perUserCache$((userId) => {
|
||||
return this.taskState(userId).state$.pipe(
|
||||
switchMap(async (tasks) => {
|
||||
if (tasks == null) {
|
||||
await this.fetchTasksFromApi(userId);
|
||||
}
|
||||
return tasks;
|
||||
}),
|
||||
filterOutNullish(),
|
||||
map((tasks) => tasks.map((t) => new SecurityTask(t))),
|
||||
);
|
||||
});
|
||||
|
||||
pendingTasks$ = perUserCache$((userId) => {
|
||||
return this.tasks$(userId).pipe(
|
||||
map((tasks) => tasks.filter((t) => t.status === SecurityTaskStatus.Pending)),
|
||||
);
|
||||
});
|
||||
|
||||
async refreshTasks(userId: UserId): Promise<void> {
|
||||
await this.fetchTasksFromApi(userId);
|
||||
}
|
||||
|
||||
async clear(userId: UserId): Promise<void> {
|
||||
await this.updateTaskState(userId, []);
|
||||
}
|
||||
|
||||
async markAsComplete(taskId: SecurityTaskId, userId: UserId): Promise<void> {
|
||||
await this.apiService.send("PATCH", `/tasks/${taskId}/complete`, null, true, false);
|
||||
await this.refreshTasks(userId);
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetches the tasks from the API and updates the local state
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private async fetchTasksFromApi(userId: UserId): Promise<void> {
|
||||
const r = await this.apiService.send("GET", "/tasks", null, true, true);
|
||||
const response = new ListResponse(r, SecurityTaskResponse);
|
||||
|
||||
const taskData = response.data.map((t) => new SecurityTaskData(t));
|
||||
await this.updateTaskState(userId, taskData);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local state for the tasks
|
||||
* @param userId
|
||||
* @private
|
||||
*/
|
||||
private taskState(userId: UserId) {
|
||||
return this.stateProvider.getUser(userId, SECURITY_TASKS);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the local state with the provided tasks and returns the updated state
|
||||
* @param userId
|
||||
* @param tasks
|
||||
* @private
|
||||
*/
|
||||
private updateTaskState(
|
||||
userId: UserId,
|
||||
tasks: SecurityTaskData[],
|
||||
): Promise<SecurityTaskData[] | null> {
|
||||
return this.taskState(userId).update(() => tasks);
|
||||
}
|
||||
}
|
||||
14
libs/common/src/vault/tasks/state/security-task.state.ts
Normal file
14
libs/common/src/vault/tasks/state/security-task.state.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
import { Jsonify } from "type-fest";
|
||||
|
||||
import { SECURITY_TASKS_DISK, UserKeyDefinition } from "@bitwarden/common/platform/state";
|
||||
|
||||
import { SecurityTaskData } from "../models/security-task.data";
|
||||
|
||||
export const SECURITY_TASKS = UserKeyDefinition.array<SecurityTaskData>(
|
||||
SECURITY_TASKS_DISK,
|
||||
"securityTasks",
|
||||
{
|
||||
deserializer: (task: Jsonify<SecurityTaskData>) => SecurityTaskData.fromJSON(task),
|
||||
clearOn: ["logout", "lock"],
|
||||
},
|
||||
);
|
||||
37
libs/common/src/vault/utils/observable-utilities.ts
Normal file
37
libs/common/src/vault/utils/observable-utilities.ts
Normal file
@@ -0,0 +1,37 @@
|
||||
import { filter, Observable, OperatorFunction, shareReplay } from "rxjs";
|
||||
|
||||
import { UserId } from "@bitwarden/common/types/guid";
|
||||
|
||||
/**
|
||||
* Builds an observable once per userId and caches it for future requests.
|
||||
* The built observables are shared among subscribers with a replay buffer size of 1.
|
||||
* @param create - A function that creates an observable for a given userId.
|
||||
*/
|
||||
export function perUserCache$<TValue>(
|
||||
create: (userId: UserId) => Observable<TValue>,
|
||||
): (userId: UserId) => Observable<TValue> {
|
||||
const cache = new Map<UserId, Observable<TValue>>();
|
||||
return (userId: UserId) => {
|
||||
let observable = cache.get(userId);
|
||||
if (!observable) {
|
||||
observable = create(userId).pipe(shareReplay({ bufferSize: 1, refCount: false }));
|
||||
cache.set(userId, observable);
|
||||
}
|
||||
return observable;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Strongly typed observable operator that filters out null/undefined values and adjusts the return type to
|
||||
* be non-nullable.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* const source$ = of(1, null, 2, undefined, 3);
|
||||
* source$.pipe(filterOutNullish()).subscribe(console.log);
|
||||
* // Output: 1, 2, 3
|
||||
* ```
|
||||
*/
|
||||
export function filterOutNullish<T>(): OperatorFunction<T | undefined | null, T> {
|
||||
return filter((v): v is T => v != null);
|
||||
}
|
||||
Reference in New Issue
Block a user