Files
dyolink/backend/src/modules/cases/cases.service.ts

575 lines
17 KiB
TypeScript
Raw Normal View History

import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { CatalogEntityKind, LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { normalizeMobile } from '../../common/phone';
import {
CatalogLabelService,
normalizeCatalogLocale,
} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
import { normalizeTaskTeeth } from './lab-case-task.util';
const labCaseListInclude = {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
appointment: { select: { startAt: true } },
},
},
details: {
include: {
detail: {
select: {
id: true,
treatmentType: true,
teeth: true,
comment: true,
},
},
},
},
sends: {
orderBy: [{ sentAt: 'asc' as const }],
include: { organization: { select: { id: true, name: true } } },
},
tasks: {
orderBy: [
{ treatmentDetailId: 'asc' as const },
{ prosthesisTypeCode: 'asc' as const },
{ stepOrder: 'asc' as const },
],
include: {
lastStatusChangedBy: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' as const },
include: { changedBy: { select: { id: true, name: true } } },
},
},
},
} satisfies Prisma.LabCaseInclude;
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
include: {
lastStatusChangedBy: { select: { id: true; name: true } };
statusEvents: {
include: { changedBy: { select: { id: true; name: true } } };
};
};
}>;
@Injectable()
export class CasesService {
constructor(
private readonly prisma: PrismaService,
private readonly treatmentCatalog: TreatmentCatalogService,
private readonly catalogLabels: CatalogLabelService,
) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
if (query.treatmentType) {
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const skip = (page - 1) * limit;
const where = this.buildListWhere(labOrganizationId, query);
const [items, total] = await Promise.all([
this.prisma.labCase.findMany({
where,
include: {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
},
},
details: {
include: {
detail: { select: { treatmentType: true } },
},
},
tasks: { select: { id: true, status: true } },
},
orderBy: [{ sentAt: 'desc' }],
skip,
take: limit,
}),
this.prisma.labCase.count({ where }),
]);
return {
success: true,
data: {
items: items.map((lc) => this.mapLabCaseListItem(lc)),
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async listFilterOptions(labOrganizationId: string, actorUserId: string) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const rows = await this.prisma.labCase.findMany({
where: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
select: {
treatment: {
select: {
organization: { select: { id: true, name: true } },
},
},
details: {
select: { detail: { select: { treatmentType: true } } },
},
},
});
const clinicsById = new Map<string, { id: string; name: string }>();
const typeCodes = new Set<string>();
for (const row of rows) {
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
for (const link of row.details) {
typeCodes.add(link.detail.treatmentType);
}
}
const catalog = await this.treatmentCatalog.list();
const treatmentTypes = catalog
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
return {
success: true,
data: {
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
treatmentTypes,
},
};
}
/** Cases exchanged between one clinic and one lab (Organizations connection history). */
async listBetweenOrganizations(
clinicOrganizationId: string,
labOrganizationId: string,
query: ListLabCasesDto,
) {
if (query.treatmentType) {
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
const skip = (page - 1) * limit;
const where: Prisma.LabCaseWhereInput = {
...this.buildListWhere(labOrganizationId, query),
treatment: { organizationId: clinicOrganizationId },
sends: { some: { organizationId: labOrganizationId } },
};
const [items, total] = await Promise.all([
this.prisma.labCase.findMany({
where,
include: {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
},
},
details: {
include: {
detail: { select: { treatmentType: true } },
},
},
tasks: { select: { id: true, status: true } },
},
orderBy: [{ sentAt: 'desc' }],
skip,
take: limit,
}),
this.prisma.labCase.count({ where }),
]);
return {
success: true,
data: {
items: items.map((lc) => this.mapLabCaseListItem(lc)),
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async getOneBetweenOrganizations(
labCaseId: string,
clinicOrganizationId: string,
labOrganizationId: string,
localeInput?: string | null,
) {
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
treatment: { organizationId: clinicOrganizationId },
sends: { some: { organizationId: labOrganizationId } },
},
include: labCaseListInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return {
success: true,
data: await this.mapLabCaseDetail(labCase, localeInput),
};
}
async getOne(
labCaseId: string,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanReadCases(actorUserId, labOrganizationId);
const labCase = await this.prisma.labCase.findFirst({
where: {
id: labCaseId,
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
include: labCaseListInclude,
});
if (!labCase) {
throw new NotFoundException('Case not found');
}
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
async updateTask(
labCaseId: string,
taskId: string,
dto: UpdateLabCaseTaskDto,
labOrganizationId: string,
actorUserId: string,
localeInput?: string | null,
) {
await this.assertCanEditCases(actorUserId, labOrganizationId);
const task = await this.prisma.labCaseTask.findFirst({
where: {
id: taskId,
labCaseId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
});
if (!task) {
throw new NotFoundException('Task not found');
}
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
data: { isImportant: dto.isImportant },
include: {
lastStatusChangedBy: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' },
include: { changedBy: { select: { id: true, name: true } } },
},
},
});
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
[updated.prosthesisTypeCode],
locale,
);
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
}
private buildListWhere(
labOrganizationId: string,
query: ListLabCasesDto,
): Prisma.LabCaseWhereInput {
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
if (query.sentFrom) {
const from = new Date(query.sentFrom);
if (Number.isNaN(from.getTime())) {
throw new BadRequestException('Invalid sentFrom date');
}
sentAtFilter.gte = from;
}
if (query.sentTo) {
const to = new Date(query.sentTo);
if (Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid sentTo date');
}
to.setHours(23, 59, 59, 999);
sentAtFilter.lte = to;
}
return {
sentAt: sentAtFilter,
sends: { some: { organizationId: labOrganizationId } },
...(query.clinicOrganizationId
? { treatment: { organizationId: query.clinicOrganizationId } }
: {}),
...(query.treatmentType
? {
details: {
some: { detail: { treatmentType: query.treatmentType } },
},
}
: {}),
...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
};
}
private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
const orConditions: Prisma.LabCaseWhereInput[] = [
{
treatment: {
patient: {
OR: [
{ firstName: { contains: q, mode: 'insensitive' } },
{ lastName: { contains: q, mode: 'insensitive' } },
],
},
},
},
{
treatment: {
organization: { name: { contains: q, mode: 'insensitive' } },
},
},
];
const normalized = normalizeMobile(q);
if (normalized) {
orConditions.push({
treatment: { patient: { mobile: normalized } },
});
}
return { OR: orConditions };
}
private mapLabCaseListItem(lc: {
id: string;
sentAt: Date | null;
treatment: {
organization: { id: string; name: string };
patient: { id: string; firstName: string; lastName: string; mobile: string };
};
details: Array<{ detail: { treatmentType: string } }>;
tasks: Array<{ id: string; status: LabTaskStatus }>;
}) {
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
return {
id: lc.id,
sentAt: lc.sentAt?.toISOString() ?? null,
clinic: lc.treatment.organization,
patient: {
id: lc.treatment.patient.id,
firstName: lc.treatment.patient.firstName,
lastName: lc.treatment.patient.lastName,
mobile: lc.treatment.patient.mobile,
},
treatmentTypes,
taskProgress: {
completed: completedTasks,
total: lc.tasks.length,
},
};
}
private async mapLabCaseDetail(
lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
localeInput?: string | null,
) {
const locale = normalizeCatalogLocale(localeInput);
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
prosthesisCodes,
locale,
);
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
return {
id: lc.id,
sentAt: lc.sentAt?.toISOString() ?? null,
clinic: lc.treatment.organization,
patient: lc.treatment.patient,
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
treatmentTypes,
details: lc.details.map((link) => ({
id: link.detail.id,
treatmentType: link.detail.treatmentType,
teeth: normalizeTeeth(link.detail.teeth),
comment: link.detail.comment,
})),
sends: lc.sends.map((s) => ({
organizationId: s.organizationId,
organizationName: s.organization.name,
sentAt: s.sentAt.toISOString(),
})),
tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
tasksByTooth,
taskProgress: {
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
total: lc.tasks.length,
},
};
}
private groupTasks(
tasks: LabCaseTaskWithRelations[],
prosthesisLabels: Map<string, string>,
) {
const groups = new Map<
string,
{
treatmentDetailId: string;
teeth: string[];
treatmentType: string;
prosthesisTypeCode: string;
prosthesisTypeLabel: string;
tasks: ReturnType<CasesService['mapTask']>[];
}
>();
for (const task of tasks) {
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
const entry = groups.get(key) ?? {
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
tasks: [],
};
entry.tasks.push(this.mapTask(task, prosthesisLabels));
groups.set(key, entry);
}
return [...groups.values()];
}
private mapTask(
task: LabCaseTaskWithRelations,
prosthesisLabels: Map<string, string>,
) {
return {
id: task.id,
treatmentDetailId: task.treatmentDetailId,
teeth: normalizeTaskTeeth(task.teeth),
treatmentType: task.treatmentType,
prosthesisTypeCode: task.prosthesisTypeCode,
prosthesisTypeLabel:
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
workflowStepCode: task.workflowStepCode ?? '',
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
isImportant: task.isImportant,
createdAt: task.createdAt.toISOString(),
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
: null,
timeline: task.statusEvents.map((event) => ({
id: event.id,
fromStatus: event.fromStatus,
toStatus: event.toStatus,
changedAt: event.changedAt.toISOString(),
changedBy: event.changedBy
? { id: event.changedBy.id, name: event.changedBy.name }
: null,
})),
};
}
private async assertCanReadCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) return;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_READ') || names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to cases');
}
private async assertCanEditCases(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) return;
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_CASES_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update cases');
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
}
}