This commit is contained in:
2026-04-29 20:22:43 +03:30
parent 993d809de2
commit ebf8a9a624
10 changed files with 487 additions and 20 deletions

View File

@@ -1,4 +1,5 @@
import { ApiProperty } from '@nestjs/swagger';
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsInt, IsOptional } from 'class-validator';
/**
* DTO for required document item
@@ -93,6 +94,12 @@ export class DamagedPartItem {
example: false,
})
captured: boolean;
/** Static catalog id (same as `damagedParts[].id` in capture requirements) when the part comes from the outer-parts catalog. */
@ApiPropertyOptional({ example: 12 })
@IsOptional()
@IsInt()
id?: number;
}
/**

View File

@@ -110,7 +110,8 @@ export class ClaimSubmitResendV2Dto {
@ApiPropertyOptional({
type: [String],
enum: ClaimRequiredDocumentType,
description: "Extra document keys to upload (may include types not in the initial claim form).",
description:
"Document keys to re-upload; each value must be a ClaimRequiredDocumentType string (e.g. national_card).",
})
@IsOptional()
@IsArray()

View File

@@ -45,6 +45,7 @@ import {
initialClaimExpertReportBuckets,
} from "src/helpers/expert-panel-status-report";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
import { BlameRequestType } from "src/Types&Enums/blame-request-management/blameRequestType.enum";
import { PartyRole } from "src/request-management/entities/schema/partyRole.enum";
import { GetClaimListV2ResponseDto, ClaimListItemV2Dto } from "./dto/claim-list-v2.dto";
@@ -229,6 +230,21 @@ export class ExpertClaimService {
});
}
/**
* Insurer tenant id stored on claim file-activity rows. Prefer `owner.userClientKey`
* so V2 events line up with `expireClaimWorkflowLockV2IfStale` and insurer `findByTenant`.
*/
private claimActivityTenantId(
claim: { owner?: { userClientKey?: unknown } } | null | undefined,
actor: { clientKey?: string },
): string {
const fromOwner = claim?.owner?.userClientKey;
if (fromOwner != null && String(fromOwner).length > 0) {
return String(fromOwner);
}
return String(actor.clientKey ?? "");
}
/** Owner mobile: linked blame party phone when available, else `users.mobile`. */
private async resolveClaimOwnerPhone(claim: any): Promise<string | undefined> {
if (!claim?.owner?.userId) return undefined;
@@ -1976,7 +1992,7 @@ export class ExpertClaimService {
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: String(actor.clientKey),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:${actor.sub}`,
@@ -2076,7 +2092,7 @@ export class ExpertClaimService {
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: String(actor.clientKey),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.UNCHECKED,
idempotencyKey: `claim:${claimRequestId}:unchecked:resend:${actor.sub}`,
@@ -2228,6 +2244,14 @@ export class ExpertClaimService {
},
});
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.CHECKED,
idempotencyKey: `claim:${claimRequestId}:checked:v2:${actor.sub}`,
});
const ownerPhone = await this.resolveClaimOwnerPhone(claim);
if (ownerPhone) {
const expertLastName =
@@ -2293,10 +2317,38 @@ export class ExpertClaimService {
);
}
const docs = reply.resendDocuments ?? [];
const parts = reply.resendCarParts ?? [];
const allowedDocTypes = new Set<string>(
Object.values(ClaimRequiredDocumentType),
);
const rawDocs = reply.resendDocuments ?? [];
const docs: ClaimRequiredDocumentType[] = [];
for (const x of rawDocs) {
const s = String(x).trim();
if (!allowedDocTypes.has(s)) {
throw new BadRequestException(
`Invalid resendDocuments value: "${String(x)}". Must be a ClaimRequiredDocumentType string (e.g. national_card, damaged_driving_license_front).`,
);
}
docs.push(s as ClaimRequiredDocumentType);
}
const uniqueDocs = [...new Set(docs)];
const normalizedParts = (reply.resendCarParts ?? []).map((p) => {
const row: Record<string, unknown> = {
key: p.key,
label_fa: p.label_fa,
label_en: p.label_en,
captured: false,
};
const id = (p as { id?: number }).id;
if (typeof id === "number" && Number.isFinite(id)) {
row.id = Math.trunc(id);
}
return row;
});
const desc = String(reply.resendDescription ?? "").trim();
if (!desc && docs.length === 0 && parts.length === 0) {
if (!desc && uniqueDocs.length === 0 && normalizedParts.length === 0) {
throw new BadRequestException(
"Provide resendDescription and/or resendDocuments and/or resendCarParts.",
);
@@ -2313,8 +2365,8 @@ export class ExpertClaimService {
"workflow.nextStep": ClaimWorkflowStep.EXPERT_DAMAGE_ASSESSMENT,
"evaluation.damageExpertResend": {
resendDescription: desc || undefined,
resendDocuments: docs,
resendCarParts: parts,
resendDocuments: uniqueDocs,
resendCarParts: normalizedParts,
...(resendSnapshot && { expertProfileSnapshot: resendSnapshot }),
},
},
@@ -2334,8 +2386,8 @@ export class ExpertClaimService {
},
timestamp: new Date(),
metadata: {
documentCount: docs.length,
carPartCount: parts.length,
documentCount: uniqueDocs.length,
carPartCount: normalizedParts.length,
},
},
},
@@ -2511,7 +2563,7 @@ export class ExpertClaimService {
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: String(actor.clientKey),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:handled:${actor.sub}`,
@@ -2616,7 +2668,7 @@ export class ExpertClaimService {
await this.recordClaimExpertActivity({
expertId: String(actor.sub),
tenantId: String(actor.clientKey),
tenantId: this.claimActivityTenantId(claim, actor),
claimId: String(claimRequestId),
eventType: ExpertFileActivityType.HANDLED,
idempotencyKey: `claim:${claimRequestId}:handled:inperson:${actor.sub}`,

View File

@@ -1,5 +1,6 @@
import { ClaimCaseStatus } from "src/Types&Enums/claim-request-management/claim-case-status.enum";
import { ClaimWorkflowStep } from "src/Types&Enums/claim-request-management/claim-workflow-steps.enum";
import { canonicalizeResendDocumentKey } from "./claim-resend-document-keys";
export function resendRequestHasPayload(r: {
resendDescription?: string;
@@ -13,21 +14,24 @@ export function resendRequestHasPayload(r: {
return false;
}
/** Normalize expert-requested document keys (strings or { key }). */
/** Normalize expert-requested document keys (strings or `{ key }`) to canonical enum strings. */
export function normalizeResendDocumentKeys(docs: unknown[] | undefined): string[] {
if (!Array.isArray(docs)) return [];
const keys: string[] = [];
for (const d of docs) {
if (typeof d === "string" && d.trim()) keys.push(d.trim());
else if (d && typeof d === "object" && "key" in (d as object)) {
if (typeof d === "string" && d.trim()) {
const c = canonicalizeResendDocumentKey(d.trim());
if (c) keys.push(c);
} else if (d && typeof d === "object" && "key" in (d as object)) {
const k = String((d as { key?: string }).key || "").trim();
if (k) keys.push(k);
const c = canonicalizeResendDocumentKey(k);
if (c) keys.push(c);
}
}
return keys;
return [...new Set(keys)];
}
/** Normalize expert-requested damaged part keys (DamagedPartItem uses `key`). */
/** Normalize expert-requested damaged part keys (`DamagedPartItem.key`). */
export function normalizeResendPartKeys(parts: unknown[] | undefined): string[] {
if (!Array.isArray(parts)) return [];
const keys: string[] = [];
@@ -94,7 +98,9 @@ export function documentKeyAllowedForExpertResend(
const r = claim?.evaluation?.damageExpertResend;
if (!r || r.fulfilledAt) return false;
const allowed = new Set(normalizeResendDocumentKeys(r.resendDocuments));
return allowed.has(documentKey);
const normalized =
canonicalizeResendDocumentKey(documentKey) ?? documentKey.trim();
return allowed.has(normalized);
}
export function partKeyAllowedForExpertResend(claim: any, partKey: string): boolean {

View File

@@ -0,0 +1,35 @@
import { ClaimRequiredDocumentType } from "src/Types&Enums/claim-request-management/required-document-type.enum";
const REQUIRED_VALUES = new Set<string>(
Object.values(ClaimRequiredDocumentType),
);
/**
* Non-canonical document keys that may still appear on stored
* `evaluation.damageExpertResend.resendDocuments` → {@link ClaimRequiredDocumentType}.
*/
const RESEND_DOCUMENT_KEY_ALIASES: Readonly<
Record<string, ClaimRequiredDocumentType>
> = {
nationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
NationalCertificate: ClaimRequiredDocumentType.NATIONAL_CARD,
drivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
DrivingLicense: ClaimRequiredDocumentType.DAMAGED_DRIVING_LICENSE_FRONT,
carGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
CarGreenCard: ClaimRequiredDocumentType.CAR_GREEN_CARD,
carCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
CarCertificate: ClaimRequiredDocumentType.DAMAGED_CAR_CARD_FRONT,
plate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
carPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
CarPlate: ClaimRequiredDocumentType.DAMAGED_METAL_PLATE,
};
/** Normalize a single key to a {@link ClaimRequiredDocumentType} value, or null if unknown. */
export function canonicalizeResendDocumentKey(
raw: string,
): ClaimRequiredDocumentType | null {
const t = String(raw || "").trim();
if (!t) return null;
if (REQUIRED_VALUES.has(t)) return t as ClaimRequiredDocumentType;
return RESEND_DOCUMENT_KEY_ALIASES[t] ?? null;
}

View File

@@ -36,3 +36,64 @@ export class InsurerPerMonthReportRowDtoRs {
this.data = data;
}
}
/** Per-expert file-activity stats (ExpertFileActivity: checked / unchecked / handled). */
export class InsurerExpertWorkLogEntryDtoRs {
expertId: string;
fullName: string;
/**
* `expert` = blame-panel expert (`expert` collection — same as insurer expert list, not `field-expert`).
* `damage_expert` = claim damage expert (`damage-expert` collection).
* Field experts (`RoleEnum.FIELD_EXPERT`, `field-expert` collection) are out of scope here.
*/
expertKind: "expert" | "damage_expert";
/** Distinct files in handled state after replaying activity up to the cutoff (or “now”). */
totalHandled: number;
/** Distinct files currently marked checked and not yet handled (same rules as expert-insurer stats). */
currentlyChecking: number;
/** Distinct files that had at least one CHECKED event in the reporting window (all-time = ever; monthly = that month only). */
distinctFilesCheckedInPeriod: number;
constructor(
expertId: string,
fullName: string,
expertKind: "expert" | "damage_expert",
totalHandled: number,
currentlyChecking: number,
distinctFilesCheckedInPeriod: number,
) {
this.expertId = expertId;
this.fullName = fullName;
this.expertKind = expertKind;
this.totalHandled = totalHandled;
this.currentlyChecking = currentlyChecking;
this.distinctFilesCheckedInPeriod = distinctFilesCheckedInPeriod;
}
}
export class InsurerExpertWorkLogResponseDtoRs {
experts: InsurerExpertWorkLogEntryDtoRs[];
constructor(experts: InsurerExpertWorkLogEntryDtoRs[]) {
this.experts = experts;
}
}
export class InsurerExpertWorkLogPerMonthRowDtoRs {
stDate: Date;
enDate: Date;
faLabel: string;
experts: InsurerExpertWorkLogEntryDtoRs[];
constructor(
stDate: Date,
enDate: Date,
faLabel: string,
experts: InsurerExpertWorkLogEntryDtoRs[],
) {
this.stDate = stDate;
this.enDate = enDate;
this.faLabel = faLabel;
this.experts = experts;
}
}

View File

@@ -67,4 +67,24 @@ export class ReportsController {
to,
);
}
@Get("report/insurer/expert-work-log")
@ApiOperation({
summary: "Insurer: per-expert work log (blame experts + damage experts)",
description:
"Rows are every tenant expert from the `expert` collection (blame / Role expert) and `damage-expert` collection (claim damage experts)—same scope as GET expert-insurer/experts/list. Field experts (`field-expert` / FIELD_EXPERT) are a different product surface and are not included. Uses expertFileActivities: totalHandled and currentlyChecking from replaying CHECKED/UNCHECKED/HANDLED; distinctFilesCheckedInPeriod = distinct files with CHECKED over all time.",
})
async getInsurerExpertWorkLog(@CurrentUser() actor) {
return await this.reportsService.getInsurerExpertWorkLog(actor);
}
@Get("report/insurer/expert-work-log/per-month")
@ApiOperation({
summary: "Insurer: blame + damage expert work log, last five calendar months",
description:
"Same expert roster as expert-work-log (expert + damage-expert collections for the tenant, not field-expert). Per month: totalHandled and currentlyChecking are snapshots after replay through that months end; distinctFilesCheckedInPeriod counts CHECKED events in that month only (distinct files).",
})
async getInsurerExpertWorkLogPerMonth(@CurrentUser() actor) {
return await this.reportsService.getInsurerExpertWorkLogPerMonth(actor);
}
}

View File

@@ -1,6 +1,7 @@
import { Module } from "@nestjs/common";
import { ClaimRequestManagementModule } from "src/claim-request-management/claim-request-management.module";
import { RequestManagementModule } from "src/request-management/request-management.module";
import { UsersModule } from "src/users/users.module";
import { ReportsController } from "./reports.controller";
import { ReportsService } from "./reports.service";
@@ -8,6 +9,7 @@ import { ReportsService } from "./reports.service";
imports: [
RequestManagementModule,
ClaimRequestManagementModule,
UsersModule,
],
controllers: [ReportsController],
providers: [ReportsService],

View File

@@ -1,4 +1,5 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Types } from "mongoose";
import { ClaimCaseDbService } from "src/claim-request-management/entites/db-service/claim-case.db.service";
import {
blameCaseStatusToReportBucket,
@@ -12,16 +13,33 @@ import {
requireActorClientKey,
} from "src/helpers/tenant-scope";
import { BlameRequestDbService } from "src/request-management/entities/db-service/blame-request.db.service";
import { DamageExpertDbService } from "src/users/entities/db-service/damage-expert.db.service";
import { ExpertDbService } from "src/users/entities/db-service/expert.db.service";
import { ExpertFileActivityDbService } from "src/users/entities/db-service/expert-file-activity.db.service";
import { ExpertFileActivityType } from "src/users/entities/schema/expert-file-activity.schema";
import {
InsurerExpertWorkLogPerMonthRowDtoRs,
InsurerExpertWorkLogResponseDtoRs,
InsurerExpertWorkLogEntryDtoRs,
InsurerPerMonthReportRowDtoRs,
InsurerRequestsSummaryDtoRs,
} from "./dto/reports.dto";
type NormalizedActivity = {
expertId: string;
fileId: string;
eventType: ExpertFileActivityType;
occurredAt: Date;
};
@Injectable()
export class ReportsService {
constructor(
private readonly blameRequestDbService: BlameRequestDbService,
private readonly claimCaseDbService: ClaimCaseDbService,
private readonly expertDbService: ExpertDbService,
private readonly damageExpertDbService: DamageExpertDbService,
private readonly expertFileActivityDbService: ExpertFileActivityDbService,
) {}
private parseDateRange(from?: string, to?: string) {
@@ -228,4 +246,251 @@ export class ReportsService {
toDate,
);
}
/** Same replay rules as `ExpertInsurerService.buildExpertActivityStatsMap`. */
private replayExpertFileStates(
events: NormalizedActivity[],
cutoff: Date,
): Map<string, { checked: boolean; handled: boolean }> {
const stateByKey = new Map<string, { checked: boolean; handled: boolean }>();
const filtered = events.filter((e) => e.occurredAt <= cutoff);
filtered.sort((a, b) => {
const t = a.occurredAt.getTime() - b.occurredAt.getTime();
if (t !== 0) return t;
return `${a.expertId}:${a.fileId}`.localeCompare(`${b.expertId}:${b.fileId}`);
});
for (const e of filtered) {
const key = `${e.expertId}:${e.fileId}`;
const prev = stateByKey.get(key) ?? { 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;
}
stateByKey.set(key, prev);
}
return stateByKey;
}
private snapshotCountsForExpert(
stateByKey: Map<string, { checked: boolean; handled: boolean }>,
expertId: string,
): { totalHandled: number; currentlyChecking: number } {
let totalHandled = 0;
let currentlyChecking = 0;
const prefix = `${expertId}:`;
for (const [key, st] of stateByKey.entries()) {
if (!key.startsWith(prefix)) continue;
if (st.handled) totalHandled++;
else if (st.checked) currentlyChecking++;
}
return { totalHandled, currentlyChecking };
}
private distinctFilesWithCheckInWindow(
events: NormalizedActivity[],
expertId: string,
from: Date,
to: Date,
): number {
const files = new Set<string>();
for (const e of events) {
if (e.expertId !== expertId) continue;
if (e.eventType !== ExpertFileActivityType.CHECKED) continue;
if (e.occurredAt < from || e.occurredAt > to) continue;
files.add(e.fileId);
}
return files.size;
}
private async loadTenantExpertsAndActivities(clientKey: string): Promise<{
events: NormalizedActivity[];
expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}>;
}> {
const tenantOid = new Types.ObjectId(clientKey);
const tenantStr = String(tenantOid);
/**
* `clientKey` is sometimes stored as ObjectId, sometimes as string — match both.
* Activity is loaded for the whole tenant so claim (V2) damage experts still appear
* even if a roster row was missed by shape mismatches; we then merge `_id` lookups.
*/
const [blamePanelExperts, damageExperts, rawEvents] = await Promise.all([
this.expertDbService.findAll({
$or: [{ clientKey: tenantStr }, { clientKey: tenantOid }],
} as never),
this.damageExpertDbService.findAll({
$or: [{ clientKey: tenantOid }, { clientKey: tenantStr }],
} as never),
this.expertFileActivityDbService.findByTenant(tenantOid),
]);
const expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}> = [];
const seenIds = new Set<string>();
const pushRow = (id: string, fullName: string, kind: "expert" | "damage_expert") => {
if (seenIds.has(id)) return;
seenIds.add(id);
expertRows.push({ id, fullName, kind });
};
for (const e of blamePanelExperts) {
const fullName =
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
(e as any).email ||
String((e as any)._id);
pushRow(String((e as any)._id), fullName, "expert");
}
for (const e of damageExperts) {
const fullName =
`${(e as any).firstName ?? ""} ${(e as any).lastName ?? ""}`.trim() ||
(e as any).email ||
String((e as any)._id);
pushRow(String((e as any)._id), fullName, "damage_expert");
}
const activityExpertIds = [
...new Set(
rawEvents
.map((row) => String(row.expertId))
.filter((id) => Types.ObjectId.isValid(id)),
),
].filter((id) => !seenIds.has(id));
if (activityExpertIds.length > 0) {
const oids = activityExpertIds.map((id) => new Types.ObjectId(id));
const [extraDamages, extraExperts] = await Promise.all([
this.damageExpertDbService.findAll({ _id: { $in: oids } } as never),
this.expertDbService.findAll({ _id: { $in: oids } } as never),
]);
for (const d of extraDamages) {
const id = String((d as any)._id);
if (seenIds.has(id)) continue;
const fullName =
`${(d as any).firstName ?? ""} ${(d as any).lastName ?? ""}`.trim() ||
(d as any).email ||
id;
pushRow(id, fullName, "damage_expert");
}
for (const x of extraExperts) {
const id = String((x as any)._id);
if (seenIds.has(id)) continue;
const fullName =
`${(x as any).firstName ?? ""} ${(x as any).lastName ?? ""}`.trim() ||
(x as any).email ||
id;
pushRow(id, fullName, "expert");
}
}
const events: NormalizedActivity[] = rawEvents.map((row) => ({
expertId: String(row.expertId),
fileId: String(row.fileId),
eventType: row.eventType,
occurredAt:
row.occurredAt instanceof Date
? row.occurredAt
: new Date(row.occurredAt as string | Date),
}));
return { events, expertRows };
}
private buildWorkLogEntries(
expertRows: Array<{
id: string;
fullName: string;
kind: "expert" | "damage_expert";
}>,
events: NormalizedActivity[],
cutoff: Date,
checkedWindow: { from: Date; to: Date },
): InsurerExpertWorkLogEntryDtoRs[] {
const state = this.replayExpertFileStates(events, cutoff);
return expertRows.map((row) => {
const snap = this.snapshotCountsForExpert(state, row.id);
return new InsurerExpertWorkLogEntryDtoRs(
row.id,
row.fullName,
row.kind,
snap.totalHandled,
snap.currentlyChecking,
this.distinctFilesWithCheckInWindow(
events,
row.id,
checkedWindow.from,
checkedWindow.to,
),
);
});
}
async getInsurerExpertWorkLog(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogResponseDtoRs> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
const now = new Date();
const epoch = new Date(0);
const entries = this.buildWorkLogEntries(expertRows, events, now, {
from: epoch,
to: now,
});
return new InsurerExpertWorkLogResponseDtoRs(entries);
}
async getInsurerExpertWorkLogPerMonth(actor: {
clientKey?: string;
}): Promise<InsurerExpertWorkLogPerMonthRowDtoRs[]> {
const clientKey = requireActorClientKey(actor);
const { events, expertRows } =
await this.loadTenantExpertsAndActivities(clientKey);
const result: InsurerExpertWorkLogPerMonthRowDtoRs[] = [];
const now = new Date();
for (let i = 0; i < 5; i++) {
const monthStart = new Date(now.getFullYear(), now.getMonth() - i, 1);
const monthEnd = new Date(
now.getFullYear(),
now.getMonth() - i + 1,
0,
23,
59,
59,
);
const label = monthStart.toLocaleDateString("fa-IR", {
month: "long",
year: "numeric",
});
const experts = this.buildWorkLogEntries(
expertRows,
events,
monthEnd,
{ from: monthStart, to: monthEnd },
);
result.unshift(
new InsurerExpertWorkLogPerMonthRowDtoRs(
monthStart,
monthEnd,
label,
experts,
),
);
}
return result;
}
}

View File

@@ -70,4 +70,22 @@ export class ExpertFileActivityDbService {
.sort({ occurredAt: 1, _id: 1 })
.lean();
}
/** All file-activity rows for an insurer tenant (blame + claim experts). */
async findByTenant(tenantId: string | Types.ObjectId): Promise<
Array<{
expertId: Types.ObjectId;
fileId: Types.ObjectId;
eventType: ExpertFileActivityType;
occurredAt: Date;
}>
> {
return this.activityModel
.find(
{ tenantId: new Types.ObjectId(String(tenantId)) },
{ expertId: 1, fileId: 1, eventType: 1, occurredAt: 1 },
)
.sort({ occurredAt: 1, _id: 1 })
.lean();
}
}