improvement: lab/clinic commiunication flow completely overhauled. no more shit.

This commit is contained in:
2026-07-13 22:53:23 +03:30
parent 2ad572f4c8
commit 27eae25f61
30 changed files with 1805 additions and 696 deletions

View File

@@ -46,6 +46,27 @@ export class TreatmentsController {
return this.treatmentsService.listLinkedOrganizations(req.user.id, organizationId);
}
@Get('lab-cases/unread')
@ApiOperation({ summary: 'Sent lab cases with unread lab activity for the organization (TAB_TREATMENT_READ)' })
listUnreadLabCases(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.listUnreadLabCases(organizationId, req.user.id);
}
@Get('patients/:patientId/lab-cases')
@ApiOperation({ summary: 'Sent lab cases for a patient with tracker summaries (TAB_TREATMENT_READ)' })
listPatientLabCases(
@Param('patientId') patientId: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
return this.treatmentsService.listPatientLabCases(
patientId,
organizationId,
req.user.id,
);
}
@Get('patients/:patientId/history')
@ApiOperation({ summary: 'List treatments for a patient (draft and completed, TAB_TREATMENT_READ)' })
listPatientHistory(

View File

@@ -19,8 +19,10 @@ import {
} from './dto/treatment.dto';
import {
isLabCaseFullyCompleted,
isLabCaseOverdue,
parseDueDateInput,
} from '../../common/lab-case-due-date';
import { CLINIC_TREATMENT_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
import {
generateTreatmentTitle,
@@ -29,6 +31,35 @@ import {
import { assertCompleteToothProsthesisMap } from './lab-case-send.validation';
import { hasEffectivePermission } from '../../common/membership-permissions';
const sentLabCaseInclude = {
treatment: {
select: {
id: true,
appointmentId: true,
treatmentAt: true,
patientId: true,
patient: { select: { id: true, firstName: true, lastName: true } },
},
},
details: {
include: {
detail: {
select: { clientKey: true, treatmentType: true, teeth: true },
},
},
},
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: { select: { status: true } },
toothProsthesis: {
select: { tooth: true, prosthesisTypeCode: true },
},
} satisfies Prisma.LabCaseInclude;
type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseInclude }>;
const treatmentInclude = {
details: {
orderBy: [{ sortOrder: 'asc' as const }],
@@ -158,6 +189,174 @@ export class TreatmentsService {
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
}
async listPatientLabCases(
patientId: string,
organizationId: string,
actorUserId: string,
) {
await this.assertCanReadTreatment(actorUserId, organizationId);
await this.ensurePatientExists(patientId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const labCases = await this.prisma.labCase.findMany({
where: {
sentAt: { not: null },
treatment: {
patientId,
organizationId,
...this.treatmentAccessFilter(isOwner, actorUserId),
},
},
include: sentLabCaseInclude,
orderBy: [{ sentAt: 'desc' }],
});
const summaries = await this.mapSentLabCaseSummaries(
labCases,
actorUserId,
organizationId,
);
return { success: true, data: this.sortLabCaseSummaries(summaries) };
}
async listUnreadLabCases(organizationId: string, actorUserId: string) {
await this.assertCanReadTreatment(actorUserId, organizationId);
const membership = await this.getMembership(actorUserId, organizationId);
const isOwner = membership?.isOwner ?? false;
const labCases = await this.prisma.labCase.findMany({
where: {
sentAt: { not: null },
treatment: {
organizationId,
...this.treatmentAccessFilter(isOwner, actorUserId),
},
},
include: sentLabCaseInclude,
orderBy: [{ sentAt: 'desc' }],
});
const caseIds = labCases.map((row) => row.id);
const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch(
actorUserId,
organizationId,
caseIds,
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
'CLINIC',
);
const unreadCases = labCases.filter((row) => unreadCaseIds.has(row.id));
const summaries = await this.mapSentLabCaseSummaries(
unreadCases,
actorUserId,
organizationId,
unreadCaseIds,
);
return { success: true, data: this.sortLabCaseSummaries(summaries) };
}
private treatmentAccessFilter(isOwner: boolean, actorUserId: string) {
return isOwner
? {}
: {
OR: [
{ providerUserId: actorUserId },
{ appointment: { is: { providerUserId: actorUserId } } },
],
};
}
private async mapSentLabCaseSummaries(
labCases: SentLabCaseRow[],
actorUserId: string,
organizationId: string,
unreadCaseIdsOverride?: Set<string>,
) {
const caseIds = labCases.map((row) => row.id);
const [unreadCaseIds, lastActivities] = await Promise.all([
unreadCaseIdsOverride ??
(await this.labCaseActivity.unreadCaseIdsInBatch(
actorUserId,
organizationId,
caseIds,
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
'CLINIC',
)),
this.labCaseActivity.getLastActivitiesForCases(caseIds, 'CLINIC'),
]);
return labCases.map((lc) => this.mapSentLabCaseSummary(lc, unreadCaseIds, lastActivities));
}
private mapSentLabCaseSummary(
lc: SentLabCaseRow,
unreadCaseIds: Set<string>,
lastActivities: Awaited<
ReturnType<LabCaseActivityService['getLastActivitiesForCases']>
>,
) {
const detailLink = lc.details[0];
const detail = detailLink?.detail;
const taskProgress = this.mapTaskProgress(lc.tasks);
const labOrg = lc.sends[lc.sends.length - 1]?.organization ?? null;
const detailTeeth = detail ? normalizeTeeth(detail.teeth) : [];
const prosthesisByCode = new Map<string, string[]>();
for (const row of lc.toothProsthesis ?? []) {
const list = prosthesisByCode.get(row.prosthesisTypeCode) ?? [];
list.push(row.tooth);
prosthesisByCode.set(row.prosthesisTypeCode, list);
}
const prosthesisGroups = [...prosthesisByCode.entries()]
.map(([prosthesisTypeCode, teeth]) => ({
prosthesisTypeCode,
teeth: [...new Set(teeth)].sort(),
}))
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
const patient = lc.treatment.patient;
return {
labCaseId: lc.id,
patientId: patient.id,
patientFirstName: patient.firstName,
patientLastName: patient.lastName,
treatmentId: lc.treatment.id,
appointmentId: lc.treatment.appointmentId,
treatmentAt: lc.treatment.treatmentAt.toISOString(),
detailClientId: detail?.clientKey ?? detailLink?.treatmentDetailId ?? '',
teeth: detailTeeth,
prosthesisGroups,
toothCount: prosthesisGroups.length
? prosthesisGroups.reduce((sum, group) => sum + group.teeth.length, 0)
: detailTeeth.length,
labOrganizationId: labOrg?.id ?? lc.destinationOrganizationId,
labName: labOrg?.name ?? 'Unknown lab',
sentAt: lc.sentAt?.toISOString() ?? null,
dueDate: lc.dueDate?.toISOString() ?? null,
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
taskProgress,
hasUnread: unreadCaseIds.has(lc.id),
lastActivity: lastActivities.get(lc.id) ?? null,
};
}
private sortLabCaseSummaries<
T extends { hasUnread: boolean; lastActivity: { createdAt: string } | null; sentAt: string | null },
>(items: T[]): T[] {
return [...items].sort((a, b) => {
if (a.hasUnread !== b.hasUnread) {
return a.hasUnread ? -1 : 1;
}
const aTime = a.lastActivity?.createdAt ?? a.sentAt ?? '';
const bTime = b.lastActivity?.createdAt ?? b.sentAt ?? '';
return bTime.localeCompare(aTime);
});
}
async getDraftForAppointment(
appointmentId: string,
organizationId: string,