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

@@ -75,12 +75,9 @@ export class LabCaseActivityService {
}
if (orgType === 'CLINIC') {
const treatment = await this.countUnreadForTab(
const treatment = await this.countUnreadLabCasesForTreatmentTab(
userId,
organizationId,
'CLINIC',
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
tabSince(LabCaseTabReadTarget.TREATMENT),
);
return { success: true, data: { treatment } };
}
@@ -106,15 +103,52 @@ export class LabCaseActivityService {
userId: string,
organizationId: string,
): Promise<number> {
return this.countUnreadLabCasesForOrg(
userId,
organizationId,
LAB_CASES_TAB_ACTIVITY_TYPES,
{
sentAt: { not: null },
sends: { some: { organizationId } },
},
'LAB',
);
}
/** Clinic Treatment tab — unread sent cases (per-case read cursor, not tab visit). */
async countUnreadLabCasesForTreatmentTab(
userId: string,
organizationId: string,
): Promise<number> {
return this.countUnreadLabCasesForOrg(
userId,
organizationId,
CLINIC_TREATMENT_TAB_ACTIVITY_TYPES,
{
sentAt: { not: null },
treatment: { organizationId },
},
'CLINIC',
);
}
private async countUnreadLabCasesForOrg(
userId: string,
organizationId: string,
types: LabCaseActivityType[],
labCaseScope: Prisma.LabCaseWhereInput,
orgType: 'LAB' | 'CLINIC',
): Promise<number> {
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
const grouped = await this.prisma.labCaseActivity.groupBy({
by: ['labCaseId'],
where: {
type: { in: LAB_CASES_TAB_ACTIVITY_TYPES },
labCase: {
sentAt: { not: null },
sends: { some: { organizationId } },
},
type: { in: types },
labCase: labCaseScope,
OR: [{ actorUserId: null }, { actorUserId: { not: userId } }],
AND: [
...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []),
],
},
_max: { createdAt: true },
});
@@ -175,6 +209,73 @@ export class LabCaseActivityService {
return unread;
}
async listForLabCase(
userId: string,
organizationId: string,
labCaseId: string,
limit = 50,
) {
const org = await this.prisma.organization.findUnique({
where: { id: organizationId },
include: { type: true },
});
if (!org) {
throw new NotFoundException('Organization not found');
}
await this.assertCanAccessCase(userId, organizationId, labCaseId);
const orgType = org.type.name === 'LAB' ? 'LAB' : 'CLINIC';
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
const activities = await this.prisma.labCaseActivity.findMany({
where: {
labCaseId,
AND: [
...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []),
],
},
include: { actorUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
take: Math.min(Math.max(limit, 1), 100),
});
const enriched = await this.enrichActivities(activities);
return { success: true, data: enriched };
}
async getLastActivitiesForCases(
labCaseIds: string[],
orgType: 'LAB' | 'CLINIC',
): Promise<Map<string, Awaited<ReturnType<LabCaseActivityService['enrichActivities']>>[number]>> {
if (labCaseIds.length === 0) return new Map();
const clinicLabCommentFilter = this.clinicLabCommentFilter(orgType);
const activities = await this.prisma.labCaseActivity.findMany({
where: {
labCaseId: { in: labCaseIds },
AND: [
...(Object.keys(clinicLabCommentFilter).length ? [clinicLabCommentFilter] : []),
],
},
include: { actorUser: { select: { name: true } } },
orderBy: { createdAt: 'desc' },
});
const latestByCase = new Map<string, (typeof activities)[number]>();
for (const activity of activities) {
if (!latestByCase.has(activity.labCaseId)) {
latestByCase.set(activity.labCaseId, activity);
}
}
const enriched = await this.enrichActivities([...latestByCase.values()]);
const map = new Map<string, (typeof enriched)[number]>();
for (const item of enriched) {
map.set(item.labCaseId, item);
}
return map;
}
async markCaseRead(userId: string, organizationId: string, labCaseId: string) {
await this.assertMembership(userId, organizationId);
await this.assertCanAccessCase(userId, organizationId, labCaseId);
@@ -190,6 +291,79 @@ export class LabCaseActivityService {
return { success: true };
}
private async enrichActivities(
activities: Array<{
id: string;
labCaseId: string;
type: LabCaseActivityType;
actorUserId: string | null;
payload: Prisma.JsonValue | null;
createdAt: Date;
actorUser: { name: string | null } | null;
}>,
) {
const commentIds: string[] = [];
const taskIds: string[] = [];
for (const activity of activities) {
const payload = activity.payload as Record<string, unknown> | null;
if (
activity.type === LabCaseActivityType.CLINIC_COMMENT ||
activity.type === LabCaseActivityType.LAB_COMMENT
) {
const commentId = payload?.commentId;
if (typeof commentId === 'string') commentIds.push(commentId);
}
if (activity.type === LabCaseActivityType.TASK_COMPLETED) {
const taskId = payload?.taskId;
if (typeof taskId === 'string') taskIds.push(taskId);
}
}
const [comments, tasks] = await Promise.all([
commentIds.length
? this.prisma.labCaseComment.findMany({
where: { id: { in: commentIds } },
select: { id: true, body: true },
})
: Promise.resolve([]),
taskIds.length
? this.prisma.labCaseTask.findMany({
where: { id: { in: taskIds } },
select: { id: true, stepLabel: true },
})
: Promise.resolve([]),
]);
const commentById = new Map<string, string>(
comments.map((row) => [row.id, row.body] as const),
);
const taskById = new Map<string, string>(
tasks.map((row) => [row.id, row.stepLabel] as const),
);
return activities.map((activity) => {
const payload = activity.payload as Record<string, unknown> | null;
const commentId =
typeof payload?.commentId === 'string' ? payload.commentId : null;
const taskId = typeof payload?.taskId === 'string' ? payload.taskId : null;
return {
id: activity.id,
labCaseId: activity.labCaseId,
type: activity.type,
createdAt: activity.createdAt.toISOString(),
actorName: activity.actorUser?.name ?? null,
commentBody: commentId ? (commentById.get(commentId) ?? null) : null,
stepLabel: taskId ? (taskById.get(taskId) ?? null) : null,
visibleToClinic:
activity.type === LabCaseActivityType.LAB_COMMENT
? payload?.visibleToClinic === true
: undefined,
};
});
}
private async countUnreadForTab(
userId: string,
organizationId: string,

View File

@@ -1,4 +1,4 @@
import { Body, Controller, Get, Post, Req, UseGuards } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseIntPipe, ParseUUIDPipe, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { MarkCaseReadDto, MarkTabReadDto } from './dto/notifications.dto';
@@ -34,6 +34,25 @@ export class NotificationsController {
return this.labCaseActivityService.markTabRead(req.user.id, organizationId, dto.tab);
}
@Get('lab-cases/:labCaseId/activities')
@ApiOperation({ summary: 'Activity feed for a lab case' })
listLabCaseActivities(
@Param('labCaseId', ParseUUIDPipe) labCaseId: string,
@Query('limit', new ParseIntPipe({ optional: true })) limit = 50,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = req.user.organizationId;
if (!organizationId) {
return { success: true, data: [] };
}
return this.labCaseActivityService.listForLabCase(
req.user.id,
organizationId,
labCaseId,
limit,
);
}
@Post('mark-case-read')
@ApiOperation({ summary: 'Mark a lab case as read for the current user' })
markCaseRead(

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,