improvement: lab/clinic commiunication flow completely overhauled. no more shit.
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user