forked from Yara724/api
YARA-885
This commit is contained in:
73
src/helpers/expert-portfolio.spec.ts
Normal file
73
src/helpers/expert-portfolio.spec.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { Types } from "mongoose";
|
||||
import {
|
||||
ExpertFileActivityType,
|
||||
} from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import { expertPortfolioFileIdsFromActivityEvents } from "./expert-portfolio";
|
||||
|
||||
describe("expertPortfolioFileIdsFromActivityEvents", () => {
|
||||
const fid = new Types.ObjectId();
|
||||
|
||||
it("includes handled files after HANDLED", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
occurredAt: new Date("2026-01-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("includes currently checked files", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("keeps handled files after a later UNCHECKED (e.g. resend unlock)", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.HANDLED,
|
||||
occurredAt: new Date("2026-01-01T10:30:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
occurredAt: new Date("2026-01-01T11:00:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(true);
|
||||
});
|
||||
|
||||
it("excludes files after UNCHECKED without HANDLED", () => {
|
||||
const ids = expertPortfolioFileIdsFromActivityEvents([
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.CHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:00:00Z"),
|
||||
},
|
||||
{
|
||||
fileId: fid,
|
||||
eventType: ExpertFileActivityType.UNCHECKED,
|
||||
occurredAt: new Date("2026-01-01T10:30:00Z"),
|
||||
},
|
||||
]);
|
||||
expect(ids.has(String(fid))).toBe(false);
|
||||
});
|
||||
});
|
||||
113
src/helpers/expert-portfolio.ts
Normal file
113
src/helpers/expert-portfolio.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { Types } from "mongoose";
|
||||
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
|
||||
import {
|
||||
blameCaseAccessibleToExpert,
|
||||
claimCaseTouchesClient,
|
||||
} from "src/helpers/tenant-scope";
|
||||
|
||||
export interface ExpertFileActivityEventRow {
|
||||
fileId: Types.ObjectId | string;
|
||||
eventType: ExpertFileActivityType;
|
||||
occurredAt: Date;
|
||||
}
|
||||
|
||||
/**
|
||||
* Distinct file ids where the expert is actively checking (CHECKED, not UNCHECKED)
|
||||
* or has handled the file (HANDLED), replayed in chronological order.
|
||||
*/
|
||||
export function expertPortfolioFileIdsFromActivityEvents(
|
||||
events: ExpertFileActivityEventRow[],
|
||||
): Set<string> {
|
||||
const sorted = [...events].sort(
|
||||
(a, b) =>
|
||||
a.occurredAt.getTime() - b.occurredAt.getTime() ||
|
||||
String(a.fileId).localeCompare(String(b.fileId)),
|
||||
);
|
||||
const stateByFile = new Map<string, { checked: boolean; handled: boolean }>();
|
||||
|
||||
for (const e of sorted) {
|
||||
const fid = String(e.fileId);
|
||||
const prev = stateByFile.get(fid) ?? { checked: false, handled: false };
|
||||
if (e.eventType === ExpertFileActivityType.CHECKED) {
|
||||
if (!prev.handled) prev.checked = true;
|
||||
} else if (e.eventType === ExpertFileActivityType.UNCHECKED) {
|
||||
prev.checked = false;
|
||||
} else if (e.eventType === ExpertFileActivityType.HANDLED) {
|
||||
prev.handled = true;
|
||||
prev.checked = false;
|
||||
}
|
||||
stateByFile.set(fid, prev);
|
||||
}
|
||||
|
||||
const ids = new Set<string>();
|
||||
for (const [fid, st] of stateByFile) {
|
||||
if (st.handled || st.checked) ids.add(fid);
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function objectIdsFromStringSet(ids: Set<string>): Types.ObjectId[] {
|
||||
return [...ids]
|
||||
.filter((id) => Types.ObjectId.isValid(id))
|
||||
.map((id) => new Types.ObjectId(id));
|
||||
}
|
||||
|
||||
/** Damage-expert claim portfolio: assigned (lock/check) or substantive work on the file. */
|
||||
export function claimDocInExpertPortfolio(
|
||||
doc: Record<string, unknown>,
|
||||
expertId: string,
|
||||
activityFileIds: Set<string>,
|
||||
clientKey: string,
|
||||
): boolean {
|
||||
if (!claimCaseTouchesClient(doc, clientKey)) return false;
|
||||
const id = String(doc._id ?? "");
|
||||
if (activityFileIds.has(id)) return true;
|
||||
|
||||
const lockedBy = (doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)
|
||||
?.lockedBy?.actorId;
|
||||
if (lockedBy != null && String(lockedBy) === String(expertId)) return true;
|
||||
|
||||
const evaluation = doc.evaluation as Record<string, unknown> | undefined;
|
||||
for (const key of ["damageExpertReply", "damageExpertReplyFinal"] as const) {
|
||||
const reply = evaluation?.[key] as
|
||||
| { actorDetail?: { actorId?: unknown } }
|
||||
| undefined;
|
||||
const actorId = reply?.actorDetail?.actorId;
|
||||
if (actorId != null && String(actorId) === String(expertId)) return true;
|
||||
}
|
||||
const resendBy = (
|
||||
evaluation?.damageExpertResend as { requestedByExpertId?: unknown } | undefined
|
||||
)?.requestedByExpertId;
|
||||
if (resendBy != null && String(resendBy) === String(expertId)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
/** Field-expert blame portfolio: initiated, decided, locked, or file-activity touch. */
|
||||
export function blameDocInExpertPortfolio(
|
||||
doc: Record<string, unknown>,
|
||||
actor: { sub: string; clientKey?: string },
|
||||
activityFileIds: Set<string>,
|
||||
): boolean {
|
||||
if (!blameCaseAccessibleToExpert(doc, actor)) return false;
|
||||
const expertId = String(actor.sub);
|
||||
const id = String(doc._id ?? "");
|
||||
if (activityFileIds.has(id)) return true;
|
||||
|
||||
if (doc.expertInitiated && doc.initiatedByFieldExpertId) {
|
||||
return String(doc.initiatedByFieldExpertId) === expertId;
|
||||
}
|
||||
|
||||
const expert = doc.expert as Record<string, unknown> | undefined;
|
||||
const decidedBy = (expert?.decision as { decidedByExpertId?: unknown } | undefined)
|
||||
?.decidedByExpertId;
|
||||
if (decidedBy != null && String(decidedBy) === expertId) return true;
|
||||
|
||||
const lockedBy =
|
||||
(expert?.resend as { requestedByExpertId?: unknown } | undefined)
|
||||
?.requestedByExpertId ??
|
||||
(doc.workflow as { lockedBy?: { actorId?: unknown } } | undefined)?.lockedBy
|
||||
?.actorId;
|
||||
if (lockedBy != null && String(lockedBy) === expertId) return true;
|
||||
|
||||
return false;
|
||||
}
|
||||
Reference in New Issue
Block a user