improvement: v1 standalone treatment/case creation made possible.
This commit is contained in:
@@ -344,9 +344,9 @@ export class AppointmentsService {
|
||||
private async ensurePatientInOrg(patientId: string, _organizationId: string) {
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: patientId },
|
||||
select: { id: true },
|
||||
select: { id: true, isWalkIn: true },
|
||||
});
|
||||
if (!patient) {
|
||||
if (!patient || patient.isWalkIn) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,30 @@ import {
|
||||
Get,
|
||||
Param,
|
||||
Patch,
|
||||
Post,
|
||||
Put,
|
||||
Query,
|
||||
Req,
|
||||
Res,
|
||||
UploadedFiles,
|
||||
UseGuards,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { FilesInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import type { Response } from 'express';
|
||||
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { ApiBearerAuth, ApiConsumes, ApiOperation, ApiTags } from '@nestjs/swagger';
|
||||
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
|
||||
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
|
||||
import { CasesService } from './cases.service';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||
import {
|
||||
ListLabCasesDto,
|
||||
UpdateLabCaseImportantDto,
|
||||
UpdateLabCaseExternalCodeDto,
|
||||
AssignLabCaseTaskDto,
|
||||
CreateLabInternalCaseDto,
|
||||
UpdateLabInternalCaseDto,
|
||||
} from './dto/cases.dto';
|
||||
|
||||
@ApiTags('cases')
|
||||
@ApiBearerAuth('JWT-auth')
|
||||
@@ -30,6 +43,13 @@ export class CasesController {
|
||||
return this.casesService.list(organizationId, req.user.id, query);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a lab-origin draft case (TAB_CASES_EDIT)' })
|
||||
create(@Body() dto: CreateLabInternalCaseDto, @Req() req) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.createInternal(dto, organizationId, req.user.id, req.user.language);
|
||||
}
|
||||
|
||||
@Get('assignable-staff')
|
||||
@ApiOperation({ summary: 'Staff who can be assigned lab tasks' })
|
||||
listAssignableStaff(@Req() req) {
|
||||
@@ -44,6 +64,13 @@ export class CasesController {
|
||||
return this.casesService.listFilterOptions(organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get('linked-clinics')
|
||||
@ApiOperation({ summary: 'Active linked clinic organizations for lab-origin cases' })
|
||||
listLinkedClinics(@Req() req) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.listLinkedClinics(organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get(':id')
|
||||
@ApiOperation({ summary: 'Get one lab case with tasks grouped by tooth' })
|
||||
getOne(@Param('id') id: string, @Req() req) {
|
||||
@@ -51,6 +78,44 @@ export class CasesController {
|
||||
return this.casesService.getOne(id, organizationId, req.user.id, req.user.language);
|
||||
}
|
||||
|
||||
@Put(':id')
|
||||
@ApiOperation({ summary: 'Update a lab-origin draft case and its lines' })
|
||||
update(@Param('id') id: string, @Body() dto: UpdateLabInternalCaseDto, @Req() req) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.updateInternal(id, dto, organizationId, req.user.id, req.user.language);
|
||||
}
|
||||
|
||||
@Post(':id/start')
|
||||
@ApiOperation({ summary: 'Start a lab-origin draft (generate tasks, no clinic notify)' })
|
||||
start(@Param('id') id: string, @Req() req) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.startInternal(id, organizationId, req.user.id, req.user.language);
|
||||
}
|
||||
|
||||
@Post(':id/lines/:lineClientKey/attachments')
|
||||
@ApiOperation({ summary: 'Upload attachments for a lab-origin draft line' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
storage: memoryStorage(),
|
||||
}),
|
||||
)
|
||||
uploadAttachments(
|
||||
@Param('id') id: string,
|
||||
@Param('lineClientKey') lineClientKey: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Req() req,
|
||||
) {
|
||||
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
|
||||
return this.casesService.uploadInternalAttachments(
|
||||
id,
|
||||
lineClientKey,
|
||||
files,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':id/attachments/:attachmentId/file')
|
||||
@ApiOperation({ summary: 'Download an attachment shared with this lab case' })
|
||||
async downloadAttachment(
|
||||
|
||||
@@ -3,8 +3,10 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { createReadStream, existsSync } from 'fs';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { randomUUID } from 'crypto';
|
||||
import { createReadStream, existsSync, mkdirSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, LinkStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -13,7 +15,8 @@ import {
|
||||
} from '../catalog/catalog-label.service';
|
||||
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
||||
import { ListLabCasesDto, UpdateLabCaseImportantDto, UpdateLabCaseExternalCodeDto, AssignLabCaseTaskDto, CreateLabInternalCaseDto, UpdateLabInternalCaseDto } from './dto/cases.dto';
|
||||
import { generateLabCaseTasks } from './lab-case-task.generator';
|
||||
import {
|
||||
isLabCaseOverdue,
|
||||
} from '../../common/lab-case-due-date';
|
||||
@@ -32,6 +35,8 @@ const labCaseListInclude = {
|
||||
appointment: { select: { startAt: true } },
|
||||
},
|
||||
},
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
lines: { orderBy: [{ sortOrder: 'asc' as const }] },
|
||||
details: {
|
||||
include: {
|
||||
detail: {
|
||||
@@ -73,6 +78,7 @@ const labCaseListInclude = {
|
||||
mimeType: true,
|
||||
sizeBytes: true,
|
||||
createdAt: true,
|
||||
detailClientKey: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
@@ -91,6 +97,8 @@ type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
||||
|
||||
@Injectable()
|
||||
export class CasesService {
|
||||
private readonly uploadRoot = join(process.cwd(), 'uploads', 'lab-cases');
|
||||
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
||||
@@ -130,6 +138,7 @@ export class CasesService {
|
||||
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
||||
},
|
||||
},
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
tasks: {
|
||||
select: {
|
||||
id: true,
|
||||
@@ -140,7 +149,7 @@ export class CasesService {
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ sentAt: 'desc' }],
|
||||
orderBy: [{ startedAt: 'desc' }, { sentAt: 'desc' }, { id: 'desc' }],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
@@ -177,11 +186,9 @@ export class CasesService {
|
||||
|
||||
const [clinicRows, taskRows] = await Promise.all([
|
||||
this.prisma.labCase.findMany({
|
||||
where: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
where: this.visibleToLabWhere(labOrganizationId),
|
||||
select: {
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
treatment: {
|
||||
select: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
@@ -191,10 +198,7 @@ export class CasesService {
|
||||
}),
|
||||
this.prisma.labCaseTask.findMany({
|
||||
where: {
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { prosthesisTypeCode: true },
|
||||
distinct: ['prosthesisTypeCode'],
|
||||
@@ -203,7 +207,12 @@ export class CasesService {
|
||||
|
||||
const clinicsById = new Map<string, { id: string; name: string }>();
|
||||
for (const row of clinicRows) {
|
||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||
if (row.treatment?.organization) {
|
||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||
}
|
||||
if (row.partnerClinic) {
|
||||
clinicsById.set(row.partnerClinic.id, row.partnerClinic);
|
||||
}
|
||||
}
|
||||
|
||||
const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
|
||||
@@ -308,6 +317,322 @@ export class CasesService {
|
||||
};
|
||||
}
|
||||
|
||||
async listLinkedClinics(labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
const [linksA, linksB] = await Promise.all([
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationAId: labOrganizationId, status: LinkStatus.ACTIVE },
|
||||
include: {
|
||||
organizationB: { select: { id: true, name: true, type: { select: { name: true } } } },
|
||||
},
|
||||
}),
|
||||
this.prisma.organizationLink.findMany({
|
||||
where: { organizationBId: labOrganizationId, status: LinkStatus.ACTIVE },
|
||||
include: {
|
||||
organizationA: { select: { id: true, name: true, type: { select: { name: true } } } },
|
||||
},
|
||||
}),
|
||||
]);
|
||||
|
||||
const data = [
|
||||
...linksA.map((l) => ({
|
||||
id: l.organizationB.id,
|
||||
name: l.organizationB.name,
|
||||
type: l.organizationB.type.name,
|
||||
})),
|
||||
...linksB.map((l) => ({
|
||||
id: l.organizationA.id,
|
||||
name: l.organizationA.name,
|
||||
type: l.organizationA.type.name,
|
||||
})),
|
||||
]
|
||||
.filter((o) => o.type === 'CLINIC')
|
||||
.map(({ id, name }) => ({ id, name, active: true }))
|
||||
.sort((a, b) => a.name.localeCompare(b.name));
|
||||
|
||||
return { success: true, data };
|
||||
}
|
||||
|
||||
async createInternal(
|
||||
dto: CreateLabInternalCaseDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId);
|
||||
|
||||
const created = await this.prisma.labCase.create({
|
||||
data: {
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
referringClinicName: dto.referringClinicName?.trim() || null,
|
||||
referringDentistName: dto.referringDentistName?.trim() || null,
|
||||
patientDisplayName: dto.patientDisplayName?.trim() || null,
|
||||
patientDisplayMobile: dto.patientDisplayMobile?.trim() || null,
|
||||
partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null,
|
||||
dueDate: dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
sortOrder: 0,
|
||||
},
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: await this.mapLabCaseDetail(created, localeInput) };
|
||||
}
|
||||
|
||||
async updateInternal(
|
||||
labCaseId: string,
|
||||
dto: UpdateLabInternalCaseDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
select: { id: true, startedAt: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (existing.startedAt) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
await this.assertPartnerClinic(dto.partnerClinicOrganizationId, labOrganizationId);
|
||||
|
||||
const saved = await this.prisma.$transaction(async (tx) => {
|
||||
await tx.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: {
|
||||
referringClinicName: dto.referringClinicName?.trim() || null,
|
||||
referringDentistName: dto.referringDentistName?.trim() || null,
|
||||
patientDisplayName: dto.patientDisplayName?.trim() || null,
|
||||
patientDisplayMobile: dto.patientDisplayMobile?.trim() || null,
|
||||
partnerClinicOrganizationId: dto.partnerClinicOrganizationId || null,
|
||||
dueDate:
|
||||
dto.dueDate === undefined ? undefined : dto.dueDate ? new Date(dto.dueDate) : null,
|
||||
},
|
||||
});
|
||||
|
||||
if (dto.lines) {
|
||||
const keepIds = dto.lines.map((l) => l.id).filter(Boolean) as string[];
|
||||
await tx.labCaseLine.deleteMany({
|
||||
where: {
|
||||
labCaseId,
|
||||
...(keepIds.length ? { id: { notIn: keepIds } } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
for (const [index, line] of dto.lines.entries()) {
|
||||
if (line.id) {
|
||||
const owned = await tx.labCaseLine.findFirst({
|
||||
where: { id: line.id, labCaseId },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!owned) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
const teeth = normalizeTeeth(line.teeth);
|
||||
const row = line.id
|
||||
? await tx.labCaseLine.update({
|
||||
where: { id: line.id },
|
||||
data: {
|
||||
clientKey: line.clientId,
|
||||
sortOrder: index,
|
||||
teeth,
|
||||
toothSelectionGroups: (line.toothSelectionGroups ??
|
||||
undefined) as Prisma.InputJsonValue | undefined,
|
||||
comment: line.comment?.trim() || null,
|
||||
},
|
||||
})
|
||||
: await tx.labCaseLine.create({
|
||||
data: {
|
||||
labCaseId,
|
||||
clientKey: line.clientId,
|
||||
sortOrder: index,
|
||||
treatmentType: 'prosthesis',
|
||||
teeth,
|
||||
toothSelectionGroups: (line.toothSelectionGroups ??
|
||||
undefined) as Prisma.InputJsonValue | undefined,
|
||||
comment: line.comment?.trim() || null,
|
||||
},
|
||||
});
|
||||
|
||||
await tx.labCaseToothProsthesis.deleteMany({ where: { lineId: row.id } });
|
||||
if (line.toothProsthesis?.length) {
|
||||
for (const tp of line.toothProsthesis) {
|
||||
this.prosthesisCatalog.assertKnownProsthesisType(tp.prosthesisTypeCode);
|
||||
}
|
||||
await tx.labCaseToothProsthesis.createMany({
|
||||
data: line.toothProsthesis.map((tp) => ({
|
||||
labCaseId,
|
||||
lineId: row.id,
|
||||
sourceKey: row.id,
|
||||
treatmentType: 'prosthesis',
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
selectionGroupId: tp.selectionGroupId?.trim() || '',
|
||||
})),
|
||||
});
|
||||
}
|
||||
|
||||
if (line.attachmentIds) {
|
||||
const lineAttachments = await tx.treatmentDetailAttachment.findMany({
|
||||
where: {
|
||||
detailClientKey: line.clientId,
|
||||
labCaseLinks: { some: { labCaseId } },
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
const keep = new Set(line.attachmentIds);
|
||||
const removeIds = lineAttachments.map((a) => a.id).filter((id) => !keep.has(id));
|
||||
if (removeIds.length) {
|
||||
await tx.labCaseAttachment.deleteMany({
|
||||
where: { labCaseId, attachmentId: { in: removeIds } },
|
||||
});
|
||||
}
|
||||
if (line.attachmentIds.length) {
|
||||
await tx.labCaseAttachment.createMany({
|
||||
data: line.attachmentIds.map((attachmentId) => ({ labCaseId, attachmentId })),
|
||||
skipDuplicates: true,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return tx.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: await this.mapLabCaseDetail(saved, localeInput) };
|
||||
}
|
||||
|
||||
async startInternal(
|
||||
labCaseId: string,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
include: { lines: true, toothProsthesis: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (existing.startedAt) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_ALREADY_STARTED, HttpStatus.CONFLICT);
|
||||
}
|
||||
if (existing.lines.length === 0 || existing.toothProsthesis.length === 0) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_START_INCOMPLETE, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const hasClient =
|
||||
Boolean(existing.referringClinicName?.trim()) ||
|
||||
Boolean(existing.patientDisplayName?.trim()) ||
|
||||
Boolean(existing.partnerClinicOrganizationId);
|
||||
if (!hasClient) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_CLIENT_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
await this.prisma.$transaction(async (tx) => {
|
||||
await tx.labCase.update({
|
||||
where: { id: labCaseId },
|
||||
data: { startedAt: new Date() },
|
||||
});
|
||||
await generateLabCaseTasks(tx, labCaseId, localeInput);
|
||||
});
|
||||
|
||||
const refreshed = await this.prisma.labCase.findFirstOrThrow({
|
||||
where: { id: labCaseId },
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
return { success: true, data: await this.mapLabCaseDetail(refreshed, localeInput) };
|
||||
}
|
||||
|
||||
async uploadInternalAttachments(
|
||||
labCaseId: string,
|
||||
lineClientKey: string,
|
||||
files: Express.Multer.File[],
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
select: { id: true, startedAt: true },
|
||||
});
|
||||
if (!existing) {
|
||||
throw new AppException(ErrorCode.CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (existing.startedAt) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_EDITABLE, HttpStatus.CONFLICT);
|
||||
}
|
||||
if (!lineClientKey?.trim()) {
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
if (!files?.length) {
|
||||
throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const orgDir = join(this.uploadRoot, labOrganizationId);
|
||||
mkdirSync(orgDir, { recursive: true });
|
||||
|
||||
const created: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
|
||||
const storagePath = join(orgDir, storageName);
|
||||
const { writeFileSync } = await import('fs');
|
||||
writeFileSync(storagePath, file.buffer);
|
||||
|
||||
const attachment = await this.prisma.treatmentDetailAttachment.create({
|
||||
data: {
|
||||
detailClientKey: lineClientKey,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
storagePath,
|
||||
},
|
||||
});
|
||||
await this.prisma.labCaseAttachment.create({
|
||||
data: { labCaseId, attachmentId: attachment.id },
|
||||
});
|
||||
created.push({
|
||||
id: attachment.id,
|
||||
fileName: attachment.fileName,
|
||||
mimeType: attachment.mimeType,
|
||||
sizeBytes: attachment.sizeBytes,
|
||||
});
|
||||
}
|
||||
|
||||
return { success: true, data: created };
|
||||
}
|
||||
|
||||
async getOne(
|
||||
labCaseId: string,
|
||||
labOrganizationId: string,
|
||||
@@ -319,8 +644,7 @@ export class CasesService {
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
@@ -344,10 +668,7 @@ export class CasesService {
|
||||
where: {
|
||||
labCaseId,
|
||||
attachmentId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
include: {
|
||||
attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
|
||||
@@ -381,8 +702,7 @@ export class CasesService {
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { id: true, isImportant: true },
|
||||
});
|
||||
@@ -432,8 +752,7 @@ export class CasesService {
|
||||
const existing = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...this.visibleToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -500,10 +819,7 @@ export class CasesService {
|
||||
where: {
|
||||
id: taskId,
|
||||
labCaseId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -590,20 +906,44 @@ export class CasesService {
|
||||
sentAtFilter.lte = to;
|
||||
}
|
||||
|
||||
return {
|
||||
const received: Prisma.LabCaseWhereInput = {
|
||||
sentAt: sentAtFilter,
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...(query.clinicOrganizationId
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.prosthesisTypeCode
|
||||
};
|
||||
|
||||
const ownedInternal: Prisma.LabCaseWhereInput = {
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
...(query.clinicOrganizationId
|
||||
? {
|
||||
tasks: {
|
||||
some: { prosthesisTypeCode: query.prosthesisTypeCode },
|
||||
},
|
||||
OR: [
|
||||
{ partnerClinicOrganizationId: query.clinicOrganizationId },
|
||||
],
|
||||
}
|
||||
: {}),
|
||||
...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
|
||||
};
|
||||
|
||||
if (query.sentFrom || query.sentTo) {
|
||||
ownedInternal.startedAt = { ...sentAtFilter };
|
||||
}
|
||||
|
||||
return {
|
||||
AND: [
|
||||
{ OR: [received, ownedInternal] },
|
||||
...(query.prosthesisTypeCode
|
||||
? [
|
||||
{
|
||||
tasks: {
|
||||
some: { prosthesisTypeCode: query.prosthesisTypeCode },
|
||||
},
|
||||
} satisfies Prisma.LabCaseWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(query.q?.trim() ? [this.buildSearchWhere(query.q.trim())] : []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
@@ -624,6 +964,10 @@ export class CasesService {
|
||||
organization: { name: { contains: q, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
{ referringClinicName: { contains: q, mode: 'insensitive' } },
|
||||
{ referringDentistName: { contains: q, mode: 'insensitive' } },
|
||||
{ patientDisplayName: { contains: q, mode: 'insensitive' } },
|
||||
{ patientDisplayMobile: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
|
||||
const normalized = normalizeMobile(q);
|
||||
@@ -636,15 +980,49 @@ export class CasesService {
|
||||
return { OR: orConditions };
|
||||
}
|
||||
|
||||
private resolveClinicAndPatient(lc: {
|
||||
origin?: LabCaseOrigin;
|
||||
referringClinicName?: string | null;
|
||||
patientDisplayName?: string | null;
|
||||
patientDisplayMobile?: string | null;
|
||||
partnerClinic?: { id: string; name: string } | null;
|
||||
treatment?: {
|
||||
organization: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||
} | null;
|
||||
}) {
|
||||
const clinic =
|
||||
lc.treatment?.organization ??
|
||||
lc.partnerClinic ?? {
|
||||
id: '',
|
||||
name: lc.referringClinicName?.trim() || '',
|
||||
};
|
||||
const displayName = lc.patientDisplayName?.trim() || '';
|
||||
const [firstName, ...rest] = displayName.split(/\s+/);
|
||||
const patient = lc.treatment?.patient ?? {
|
||||
id: '',
|
||||
firstName: firstName || displayName || clinic.name || '—',
|
||||
lastName: rest.join(' '),
|
||||
mobile: lc.patientDisplayMobile?.trim() || '',
|
||||
};
|
||||
return { clinic, patient };
|
||||
}
|
||||
|
||||
private mapLabCaseListItem(lc: {
|
||||
id: string;
|
||||
sentAt: Date | null;
|
||||
startedAt?: Date | null;
|
||||
dueDate: Date | null;
|
||||
isImportant: boolean;
|
||||
origin?: LabCaseOrigin;
|
||||
referringClinicName?: string | null;
|
||||
patientDisplayName?: string | null;
|
||||
patientDisplayMobile?: string | null;
|
||||
partnerClinic?: { id: string; name: string } | null;
|
||||
treatment: {
|
||||
organization: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||
};
|
||||
} | null;
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
status: LabTaskStatus;
|
||||
@@ -654,20 +1032,18 @@ export class CasesService {
|
||||
}) {
|
||||
const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks);
|
||||
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
||||
const { clinic, patient } = this.resolveClinicAndPatient(lc);
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
startedAt: lc.startedAt?.toISOString() ?? null,
|
||||
origin: lc.origin ?? LabCaseOrigin.CLINIC_DISPATCH,
|
||||
dueDate: lc.dueDate?.toISOString() ?? null,
|
||||
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||
isImportant: lc.isImportant,
|
||||
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,
|
||||
},
|
||||
clinic,
|
||||
patient,
|
||||
prosthesisGroups,
|
||||
taskProgress: {
|
||||
completed: completedTasks,
|
||||
@@ -681,8 +1057,10 @@ export class CasesService {
|
||||
localeInput?: string | null,
|
||||
) {
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
|
||||
const treatmentType =
|
||||
lc.details[0]?.detail?.treatmentType ?? lc.lines[0]?.treatmentType ?? null;
|
||||
const link = lc.details[0];
|
||||
const { clinic, patient } = this.resolveClinicAndPatient(lc);
|
||||
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
||||
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
||||
CatalogEntityKind.PROSTHESIS_TYPE,
|
||||
@@ -704,21 +1082,44 @@ export class CasesService {
|
||||
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
||||
isImportant: lc.isImportant,
|
||||
externalCode: lc.externalCode ?? null,
|
||||
origin: lc.origin,
|
||||
startedAt: lc.startedAt?.toISOString() ?? null,
|
||||
referringClinicName: lc.referringClinicName,
|
||||
referringDentistName: lc.referringDentistName,
|
||||
patientDisplayName: lc.patientDisplayName,
|
||||
patientDisplayMobile: lc.patientDisplayMobile,
|
||||
partnerClinicOrganizationId: lc.partnerClinicOrganizationId,
|
||||
shareUrl,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
clinic,
|
||||
patient,
|
||||
appointmentStartAt: lc.treatment?.appointment?.startAt?.toISOString() ?? null,
|
||||
treatmentType,
|
||||
detail: link
|
||||
detail: link?.detail
|
||||
? {
|
||||
id: link.detail.id,
|
||||
treatmentType: link.detail.treatmentType,
|
||||
teeth: normalizeTeeth(link.detail.teeth),
|
||||
comment: link.detail.comment,
|
||||
}
|
||||
: null,
|
||||
: lc.lines[0]
|
||||
? {
|
||||
id: lc.lines[0].id,
|
||||
treatmentType: lc.lines[0].treatmentType,
|
||||
teeth: normalizeTeeth(lc.lines[0].teeth),
|
||||
comment: lc.lines[0].comment,
|
||||
}
|
||||
: null,
|
||||
lines: lc.lines.map((line) => ({
|
||||
id: line.id,
|
||||
clientId: line.clientKey ?? line.id,
|
||||
treatmentType: line.treatmentType,
|
||||
teeth: normalizeTeeth(line.teeth),
|
||||
toothSelectionGroups: line.toothSelectionGroups,
|
||||
comment: line.comment,
|
||||
})),
|
||||
toothProsthesis: lc.toothProsthesis.map((row) => ({
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
lineId: row.lineId,
|
||||
tooth: row.tooth,
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
selectionGroupId: row.selectionGroupId?.trim() || undefined,
|
||||
@@ -729,6 +1130,7 @@ export class CasesService {
|
||||
mimeType: row.attachment.mimeType,
|
||||
sizeBytes: row.attachment.sizeBytes,
|
||||
createdAt: row.attachment.createdAt.toISOString(),
|
||||
detailClientKey: row.attachment.detailClientKey,
|
||||
})),
|
||||
sends: lc.sends.map((s) => ({
|
||||
organizationId: s.organizationId,
|
||||
@@ -806,9 +1208,9 @@ export class CasesService {
|
||||
|
||||
for (const task of tasks) {
|
||||
const selectionGroupId = task.selectionGroupId ?? '';
|
||||
const key = `${task.treatmentDetailId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
|
||||
const key = `${task.sourceKey ?? task.treatmentDetailId ?? task.lineId}:${selectionGroupId}:${task.prosthesisTypeCode}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
@@ -839,7 +1241,7 @@ export class CasesService {
|
||||
) {
|
||||
return {
|
||||
id: task.id,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey ?? task.lineId ?? '',
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
@@ -870,6 +1272,72 @@ export class CasesService {
|
||||
};
|
||||
}
|
||||
|
||||
private visibleToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
startedAt: { not: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private async assertPartnerClinic(
|
||||
partnerId: string | null | undefined,
|
||||
labOrganizationId: string,
|
||||
) {
|
||||
if (!partnerId) return;
|
||||
const org = await this.prisma.organization.findUnique({
|
||||
where: { id: partnerId },
|
||||
select: { id: true, type: { select: { name: true } } },
|
||||
});
|
||||
if (!org || org.type.name !== 'CLINIC') {
|
||||
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
const [linksA, linksB] = await Promise.all([
|
||||
this.prisma.organizationLink.findFirst({
|
||||
where: {
|
||||
organizationAId: labOrganizationId,
|
||||
organizationBId: partnerId,
|
||||
status: LinkStatus.ACTIVE,
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
this.prisma.organizationLink.findFirst({
|
||||
where: {
|
||||
organizationAId: partnerId,
|
||||
organizationBId: labOrganizationId,
|
||||
status: LinkStatus.ACTIVE,
|
||||
},
|
||||
select: { id: true },
|
||||
}),
|
||||
]);
|
||||
if (!linksA && !linksB) {
|
||||
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
}
|
||||
|
||||
private async assertCanReadCases(userId: string, organizationId: string) {
|
||||
const m = await this.getMembership(userId, organizationId);
|
||||
if (!m) {
|
||||
|
||||
@@ -1,5 +1,18 @@
|
||||
import { Transform } from 'class-transformer';
|
||||
import { IsBoolean, IsDateString, IsInt, IsOptional, IsString, IsUUID, Max, MaxLength, Min } from 'class-validator';
|
||||
import { Transform, Type } from 'class-transformer';
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsInt,
|
||||
IsOptional,
|
||||
IsString,
|
||||
IsUUID,
|
||||
Max,
|
||||
MaxLength,
|
||||
Min,
|
||||
ValidateNested,
|
||||
} from 'class-validator';
|
||||
|
||||
export class UpdateLabCaseImportantDto {
|
||||
@IsBoolean()
|
||||
@@ -53,3 +66,105 @@ export class ListLabCasesDto {
|
||||
@Max(100)
|
||||
limit = 20;
|
||||
}
|
||||
|
||||
export class ToothSelectionGroupDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
groupId: string;
|
||||
|
||||
@IsString()
|
||||
kind: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
teeth: string[];
|
||||
}
|
||||
|
||||
export class LabInternalToothProsthesisDto {
|
||||
@IsString()
|
||||
@MaxLength(8)
|
||||
tooth: string;
|
||||
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
prosthesisTypeCode: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
selectionGroupId?: string;
|
||||
}
|
||||
|
||||
export class LabInternalCaseLineDto {
|
||||
@IsString()
|
||||
@MaxLength(64)
|
||||
clientId: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
id?: string;
|
||||
|
||||
@IsArray()
|
||||
@IsString({ each: true })
|
||||
teeth: string[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => ToothSelectionGroupDto)
|
||||
toothSelectionGroups?: ToothSelectionGroupDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(5000)
|
||||
comment?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LabInternalToothProsthesisDto)
|
||||
toothProsthesis?: LabInternalToothProsthesisDto[];
|
||||
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@IsUUID(undefined, { each: true })
|
||||
attachmentIds?: string[];
|
||||
}
|
||||
|
||||
export class CreateLabInternalCaseDto {
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
referringClinicName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
referringDentistName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(200)
|
||||
patientDisplayName?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
@MaxLength(32)
|
||||
patientDisplayMobile?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
partnerClinicOrganizationId?: string | null;
|
||||
|
||||
@IsOptional()
|
||||
@IsDateString()
|
||||
dueDate?: string | null;
|
||||
}
|
||||
|
||||
export class UpdateLabInternalCaseDto extends CreateLabInternalCaseDto {
|
||||
@IsOptional()
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => LabInternalCaseLineDto)
|
||||
lines?: LabInternalCaseLineDto[];
|
||||
}
|
||||
|
||||
@@ -128,9 +128,16 @@ export class LabCaseAccessService {
|
||||
dueDate: labCase.dueDate?.toISOString() ?? null,
|
||||
isOverdue: isLabCaseOverdue(labCase.dueDate, labCase.tasks),
|
||||
isImportant: labCase.isImportant,
|
||||
clinic: labCase.treatment.organization,
|
||||
clinic: labCase.treatment?.organization ?? {
|
||||
id: '',
|
||||
name: '',
|
||||
},
|
||||
lab: labCase.sends[0]?.organization ?? null,
|
||||
patient: labCase.treatment.patient,
|
||||
patient: labCase.treatment?.patient ?? {
|
||||
id: '',
|
||||
firstName: '',
|
||||
lastName: '',
|
||||
},
|
||||
prosthesisGroups,
|
||||
},
|
||||
};
|
||||
@@ -240,7 +247,7 @@ export class LabCaseAccessService {
|
||||
actorUserId: string,
|
||||
organizationId: string,
|
||||
): Promise<ResolvedAccess> {
|
||||
const clinicOrgId = labCase.treatment.organization.id;
|
||||
const clinicOrgId = labCase.treatment?.organization.id;
|
||||
const labOrgId =
|
||||
labCase.destinationOrganizationId ?? labCase.sends[0]?.organizationId ?? null;
|
||||
|
||||
@@ -266,13 +273,13 @@ export class LabCaseAccessService {
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
|
||||
if (organizationId === clinicOrgId) {
|
||||
if (clinicOrgId && organizationId === clinicOrgId) {
|
||||
const membership = await this.getMembership(actorUserId, clinicOrgId);
|
||||
if (!membership) return { kind: 'denied' };
|
||||
if (!hasEffectivePermission(membership, 'TAB_TREATMENT_EDIT')) {
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
|
||||
if (!labCase.treatment || !isActorTreatmentProvider(labCase.treatment, actorUserId)) {
|
||||
return { kind: 'denied' };
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -8,7 +8,9 @@ import { generateLabCaseTasks } from './lab-case-task.generator';
|
||||
function buildMockTx(options: {
|
||||
existingCount?: number;
|
||||
toothProsthesisRows: Array<{
|
||||
treatmentDetailId: string;
|
||||
treatmentDetailId?: string | null;
|
||||
lineId?: string | null;
|
||||
sourceKey?: string;
|
||||
tooth: string;
|
||||
prosthesisTypeCode: string;
|
||||
selectionGroupId?: string;
|
||||
@@ -33,15 +35,27 @@ function buildMockTx(options: {
|
||||
labCaseToothProsthesis: {
|
||||
findMany: jest.fn().mockResolvedValue(
|
||||
options.toothProsthesisRows.map((row) => ({
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
treatmentDetailId: row.treatmentDetailId ?? null,
|
||||
lineId: row.lineId ?? null,
|
||||
sourceKey: row.sourceKey ?? row.treatmentDetailId ?? row.lineId ?? '',
|
||||
treatmentType: row.treatmentType ?? 'prosthesis',
|
||||
tooth: row.tooth,
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
selectionGroupId: row.selectionGroupId ?? '',
|
||||
detail: {
|
||||
id: row.treatmentDetailId,
|
||||
treatmentType: row.treatmentType ?? 'prosthesis',
|
||||
toothSelectionGroups: null,
|
||||
},
|
||||
detail: row.treatmentDetailId
|
||||
? {
|
||||
id: row.treatmentDetailId,
|
||||
treatmentType: row.treatmentType ?? 'prosthesis',
|
||||
toothSelectionGroups: null,
|
||||
}
|
||||
: null,
|
||||
line: row.lineId
|
||||
? {
|
||||
id: row.lineId,
|
||||
treatmentType: row.treatmentType ?? 'prosthesis',
|
||||
toothSelectionGroups: null,
|
||||
}
|
||||
: null,
|
||||
})),
|
||||
),
|
||||
},
|
||||
@@ -219,4 +233,34 @@ describe('generateLabCaseTasks', () => {
|
||||
expect(count).toBe(0);
|
||||
expect(tx.labCaseTask.createMany).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('creates tasks from lab-origin lines using sourceKey and lineId', async () => {
|
||||
const pfmSteps = stepsFromSeed('pfm_crown');
|
||||
const { tx, created } = buildMockTx({
|
||||
toothProsthesisRows: [
|
||||
{
|
||||
lineId: 'line-1',
|
||||
sourceKey: 'line-1',
|
||||
tooth: '11',
|
||||
prosthesisTypeCode: 'pfm_crown',
|
||||
},
|
||||
{
|
||||
lineId: 'line-1',
|
||||
sourceKey: 'line-1',
|
||||
tooth: '21',
|
||||
prosthesisTypeCode: 'pfm_crown',
|
||||
},
|
||||
],
|
||||
prosthesisTypes: [{ code: 'pfm_crown', steps: pfmSteps }],
|
||||
});
|
||||
|
||||
const count = await generateLabCaseTasks(tx as never, 'lab-internal-1', 'en');
|
||||
expect(count).toBe(pfmSteps.length);
|
||||
expect(created[0]).toMatchObject({
|
||||
lineId: 'line-1',
|
||||
sourceKey: 'line-1',
|
||||
treatmentDetailId: null,
|
||||
teeth: ['11', '21'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -19,6 +19,7 @@ export async function generateLabCaseTasks(
|
||||
where: { labCaseId },
|
||||
include: {
|
||||
detail: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
|
||||
line: { select: { id: true, treatmentType: true, toothSelectionGroups: true } },
|
||||
},
|
||||
});
|
||||
|
||||
@@ -64,7 +65,9 @@ export async function generateLabCaseTasks(
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
treatmentDetailId: string;
|
||||
sourceKey: string;
|
||||
treatmentDetailId: string | null;
|
||||
lineId: string | null;
|
||||
treatmentType: string;
|
||||
prosthesisTypeCode: string;
|
||||
selectionGroupId: string;
|
||||
@@ -73,11 +76,23 @@ export async function generateLabCaseTasks(
|
||||
>();
|
||||
|
||||
for (const row of toothProsthesisRows) {
|
||||
const key = `${row.treatmentDetailId}::${row.prosthesisTypeCode}`;
|
||||
const sourceKey =
|
||||
row.sourceKey ||
|
||||
row.treatmentDetailId ||
|
||||
row.lineId ||
|
||||
'';
|
||||
if (!sourceKey) continue;
|
||||
const key = `${sourceKey}::${row.prosthesisTypeCode}`;
|
||||
const selectionGroupId = row.selectionGroupId?.trim() || '';
|
||||
const group = groups.get(key) ?? {
|
||||
sourceKey,
|
||||
treatmentDetailId: row.treatmentDetailId,
|
||||
treatmentType: row.detail.treatmentType,
|
||||
lineId: row.lineId,
|
||||
treatmentType:
|
||||
row.detail?.treatmentType ??
|
||||
row.line?.treatmentType ??
|
||||
row.treatmentType ??
|
||||
'prosthesis',
|
||||
prosthesisTypeCode: row.prosthesisTypeCode,
|
||||
selectionGroupId,
|
||||
teeth: [],
|
||||
@@ -104,6 +119,8 @@ export async function generateLabCaseTasks(
|
||||
taskRows.push({
|
||||
labCaseId,
|
||||
treatmentDetailId: group.treatmentDetailId,
|
||||
lineId: group.lineId,
|
||||
sourceKey: group.sourceKey,
|
||||
teeth,
|
||||
treatmentType: group.treatmentType,
|
||||
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||
|
||||
@@ -31,7 +31,7 @@ export class LabCaseCommentsController {
|
||||
@Get(':caseId')
|
||||
@ApiOperation({ summary: 'List comments for a lab case (lab side)' })
|
||||
list(@Param('caseId') caseId: string, @Req() req) {
|
||||
return this.service.listForLab(caseId, this.orgId(req), req.user.id);
|
||||
return this.service.listForLabViewer(caseId, this.orgId(req), req.user.id);
|
||||
}
|
||||
|
||||
@Post(':caseId')
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { LabCaseCommentSide, LabCaseActivityType, LabCaseOrigin, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { CreateLabCaseCommentDto } from './dto/lab-case-comment.dto';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
@@ -290,8 +290,16 @@ export class LabCaseCommentsService {
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: caseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
},
|
||||
],
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -398,8 +406,11 @@ export class LabCaseCommentsService {
|
||||
private async clinicOrgIdForCase(caseId: string): Promise<string | null> {
|
||||
const labCase = await this.prisma.labCase.findUnique({
|
||||
where: { id: caseId },
|
||||
select: { treatment: { select: { organizationId: true } } },
|
||||
select: {
|
||||
partnerClinicOrganizationId: true,
|
||||
treatment: { select: { organizationId: true } },
|
||||
},
|
||||
});
|
||||
return labCase?.treatment.organizationId ?? null;
|
||||
return labCase?.treatment?.organizationId ?? labCase?.partnerClinicOrganizationId ?? null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,11 +3,7 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import {
|
||||
LabCaseActivityType,
|
||||
LabCaseTabReadTarget,
|
||||
Prisma,
|
||||
} from '@prisma/client';
|
||||
import { LabCaseActivityType, LabCaseOrigin, LabCaseTabReadTarget, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { hasEffectivePermission } from '../../common/membership-permissions';
|
||||
import {
|
||||
@@ -476,8 +472,16 @@ export class LabCaseActivityService {
|
||||
org.type.name === 'LAB'
|
||||
? {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: organizationId,
|
||||
},
|
||||
],
|
||||
}
|
||||
: {
|
||||
id: labCaseId,
|
||||
|
||||
@@ -54,6 +54,10 @@ export class UserNotificationService {
|
||||
select: {
|
||||
id: true,
|
||||
destinationOrganizationId: true,
|
||||
origin: true,
|
||||
referringClinicName: true,
|
||||
patientDisplayName: true,
|
||||
partnerClinic: { select: { name: true } },
|
||||
treatment: {
|
||||
select: {
|
||||
organization: { select: { name: true } },
|
||||
@@ -70,7 +74,7 @@ export class UserNotificationService {
|
||||
},
|
||||
});
|
||||
|
||||
if (!labCase?.treatment) {
|
||||
if (!labCase) {
|
||||
return { labCaseId, ...(extra ?? {}) };
|
||||
}
|
||||
|
||||
@@ -92,16 +96,21 @@ export class UserNotificationService {
|
||||
),
|
||||
];
|
||||
|
||||
const patient = labCase.treatment.patient;
|
||||
const patientName = `${patient.firstName} ${patient.lastName}`.trim();
|
||||
const patient = labCase.treatment?.patient;
|
||||
const patientName = patient
|
||||
? `${patient.firstName} ${patient.lastName}`.trim()
|
||||
: labCase.patientDisplayName?.trim() || '';
|
||||
const clinicName =
|
||||
labCase.treatment?.organization.name ??
|
||||
labCase.partnerClinic?.name ??
|
||||
labCase.referringClinicName?.trim() ??
|
||||
'';
|
||||
|
||||
// Caller ids (taskId, commentId, …) come from `extra`; denormalized display
|
||||
// fields must win so they are never overwritten by a thin emit payload.
|
||||
return {
|
||||
...(extra ?? {}),
|
||||
labCaseId,
|
||||
patientName,
|
||||
clinicName: labCase.treatment.organization.name,
|
||||
clinicName,
|
||||
labName,
|
||||
prosthesisTypeCodes,
|
||||
};
|
||||
@@ -340,8 +349,9 @@ export class UserNotificationService {
|
||||
},
|
||||
});
|
||||
if (!labCase?.treatment) return [];
|
||||
const treatment = labCase.treatment;
|
||||
userIds = userIds.filter((userId) =>
|
||||
isActorTreatmentProvider(labCase.treatment, userId),
|
||||
isActorTreatmentProvider(treatment, userId),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -43,9 +43,10 @@ export class PatientsService {
|
||||
const { page = 1, limit = 10, q } = query;
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where = q?.trim()
|
||||
? this.buildSearchWhere(q.trim())
|
||||
: {};
|
||||
const where = {
|
||||
isWalkIn: false,
|
||||
...(q?.trim() ? this.buildSearchWhere(q.trim()) : {}),
|
||||
};
|
||||
|
||||
const [items, total] = await Promise.all([
|
||||
this.prisma.patient.findMany({
|
||||
@@ -76,7 +77,7 @@ export class PatientsService {
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!patient) {
|
||||
if (!patient || patient.isWalkIn) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@@ -85,6 +86,13 @@ export class PatientsService {
|
||||
|
||||
async update(id: string, updatePatientDto: UpdatePatientDto) {
|
||||
await this.ensurePatient(id);
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id },
|
||||
select: { isWalkIn: true },
|
||||
});
|
||||
if (patient?.isWalkIn) {
|
||||
throw new AppException(ErrorCode.PATIENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
const data: {
|
||||
firstName?: string;
|
||||
@@ -116,12 +124,12 @@ export class PatientsService {
|
||||
: null;
|
||||
}
|
||||
|
||||
const patient = await this.prisma.patient.update({
|
||||
const updated = await this.prisma.patient.update({
|
||||
where: { id },
|
||||
data,
|
||||
});
|
||||
|
||||
return { success: true, data: patient };
|
||||
return { success: true, data: updated };
|
||||
}
|
||||
|
||||
async listAppointments(
|
||||
|
||||
@@ -3,7 +3,7 @@ import {
|
||||
Injectable,
|
||||
} from '@nestjs/common';
|
||||
import { AppException, ErrorCode } from '../../common/errors';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { CatalogEntityKind, LabCaseActivityType, LabCaseOrigin, LabTaskStatus, Prisma, UserNotificationType } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import {
|
||||
@@ -23,6 +23,7 @@ const taskListInclude = {
|
||||
labCase: {
|
||||
include: {
|
||||
tasks: { select: { status: true } },
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
treatment: {
|
||||
include: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
@@ -110,8 +111,7 @@ export class TasksService {
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId } },
|
||||
...this.visibleStartedToLabWhere(organizationId),
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
@@ -129,6 +129,7 @@ export class TasksService {
|
||||
include: taskListInclude,
|
||||
orderBy: [
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ sourceKey: 'asc' },
|
||||
{ prosthesisTypeCode: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
@@ -164,17 +165,15 @@ export class TasksService {
|
||||
const target = await this.prisma.labCaseTask.findFirst({
|
||||
where: {
|
||||
id: query.taskId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
},
|
||||
include: {
|
||||
labCase: { select: { sentAt: true } },
|
||||
labCase: { select: { sentAt: true, startedAt: true } },
|
||||
},
|
||||
});
|
||||
|
||||
if (!target?.labCase.sentAt) {
|
||||
const effectiveAt = target?.labCase.sentAt ?? target?.labCase.startedAt;
|
||||
if (!target || !effectiveAt) {
|
||||
throw new AppException(ErrorCode.CASE_TASK_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
@@ -194,9 +193,9 @@ export class TasksService {
|
||||
where,
|
||||
listQuery,
|
||||
{
|
||||
sentAt: target.labCase.sentAt,
|
||||
sentAt: effectiveAt,
|
||||
labCaseId: target.labCaseId,
|
||||
treatmentDetailId: target.treatmentDetailId,
|
||||
sourceKey: target.sourceKey,
|
||||
prosthesisTypeCode: target.prosthesisTypeCode,
|
||||
stepOrder: target.stepOrder,
|
||||
id: target.id,
|
||||
@@ -225,10 +224,7 @@ export class TasksService {
|
||||
const task = await this.prisma.labCaseTask.findFirst({
|
||||
where: {
|
||||
id: taskId,
|
||||
labCase: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
labCase: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
},
|
||||
include: taskListInclude,
|
||||
});
|
||||
@@ -366,11 +362,9 @@ export class TasksService {
|
||||
await this.assertCanReadTasks(actorUserId, labOrganizationId);
|
||||
|
||||
const rows = await this.prisma.labCase.findMany({
|
||||
where: {
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
where: this.visibleStartedToLabWhere(labOrganizationId),
|
||||
select: {
|
||||
partnerClinic: { select: { id: true, name: true } },
|
||||
treatment: {
|
||||
select: {
|
||||
organization: { select: { id: true, name: true } },
|
||||
@@ -381,7 +375,12 @@ export class TasksService {
|
||||
|
||||
const clinicsById = new Map<string, { id: string; name: string }>();
|
||||
for (const row of rows) {
|
||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||
if (row.treatment?.organization) {
|
||||
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
||||
}
|
||||
if (row.partnerClinic) {
|
||||
clinicsById.set(row.partnerClinic.id, row.partnerClinic);
|
||||
}
|
||||
}
|
||||
|
||||
const locale = normalizeCatalogLocale(localeInput);
|
||||
@@ -442,12 +441,33 @@ export class TasksService {
|
||||
}
|
||||
|
||||
const labCaseScope: Prisma.LabCaseWhereInput = {
|
||||
sentAt: sentAtFilter,
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
...(query.clinicOrganizationId
|
||||
? { treatment: { organizationId: query.clinicOrganizationId } }
|
||||
: {}),
|
||||
...(query.q?.trim() ? { treatment: this.buildSearchWhere(query.q.trim()) } : {}),
|
||||
AND: [
|
||||
this.visibleStartedToLabWhere(labOrganizationId),
|
||||
...(query.sentFrom || query.sentTo
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ sentAt: sentAtFilter },
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
startedAt: sentAtFilter,
|
||||
},
|
||||
],
|
||||
} satisfies Prisma.LabCaseWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(query.clinicOrganizationId
|
||||
? [
|
||||
{
|
||||
OR: [
|
||||
{ treatment: { organizationId: query.clinicOrganizationId } },
|
||||
{ partnerClinicOrganizationId: query.clinicOrganizationId },
|
||||
],
|
||||
} satisfies Prisma.LabCaseWhereInput,
|
||||
]
|
||||
: []),
|
||||
...(query.q?.trim() ? [this.buildTaskSearchWhere(query.q.trim())] : []),
|
||||
],
|
||||
};
|
||||
|
||||
const base: Prisma.LabCaseTaskWhereInput = {
|
||||
@@ -475,7 +495,7 @@ export class TasksService {
|
||||
}
|
||||
|
||||
const completedGroups = await this.prisma.labCaseTask.groupBy({
|
||||
by: ['labCaseId', 'treatmentDetailId', 'prosthesisTypeCode'],
|
||||
by: ['labCaseId', 'sourceKey', 'prosthesisTypeCode'],
|
||||
where: {
|
||||
workflowStepCode: stepCompleted,
|
||||
status: LabTaskStatus.COMPLETED,
|
||||
@@ -493,7 +513,7 @@ export class TasksService {
|
||||
{
|
||||
OR: completedGroups.map((group) => ({
|
||||
labCaseId: group.labCaseId,
|
||||
treatmentDetailId: group.treatmentDetailId,
|
||||
sourceKey: group.sourceKey,
|
||||
prosthesisTypeCode: group.prosthesisTypeCode,
|
||||
})),
|
||||
},
|
||||
@@ -529,7 +549,7 @@ export class TasksService {
|
||||
target: {
|
||||
sentAt: Date;
|
||||
labCaseId: string;
|
||||
treatmentDetailId: string;
|
||||
sourceKey: string;
|
||||
prosthesisTypeCode: string;
|
||||
stepOrder: number;
|
||||
id: string;
|
||||
@@ -543,7 +563,11 @@ export class TasksService {
|
||||
}
|
||||
|
||||
const sentAt = target.sentAt;
|
||||
const sameSentAt = { labCase: { sentAt } };
|
||||
const sameSentAt = {
|
||||
labCase: {
|
||||
OR: [{ sentAt }, { AND: [{ sentAt: null }, { startedAt: sentAt }] }],
|
||||
},
|
||||
};
|
||||
const tupleBefore: Prisma.LabCaseTaskWhereInput[] = [
|
||||
{
|
||||
AND: [sameSentAt, { labCaseId: { lt: target.labCaseId } }],
|
||||
@@ -552,14 +576,14 @@ export class TasksService {
|
||||
AND: [
|
||||
sameSentAt,
|
||||
{ labCaseId: target.labCaseId },
|
||||
{ treatmentDetailId: { lt: target.treatmentDetailId } },
|
||||
{ sourceKey: { lt: target.sourceKey } },
|
||||
],
|
||||
},
|
||||
{
|
||||
AND: [
|
||||
sameSentAt,
|
||||
{ labCaseId: target.labCaseId },
|
||||
{ treatmentDetailId: target.treatmentDetailId },
|
||||
{ sourceKey: target.sourceKey },
|
||||
{ prosthesisTypeCode: { lt: target.prosthesisTypeCode } },
|
||||
],
|
||||
},
|
||||
@@ -567,7 +591,7 @@ export class TasksService {
|
||||
AND: [
|
||||
sameSentAt,
|
||||
{ labCaseId: target.labCaseId },
|
||||
{ treatmentDetailId: target.treatmentDetailId },
|
||||
{ sourceKey: target.sourceKey },
|
||||
{ prosthesisTypeCode: target.prosthesisTypeCode },
|
||||
{ stepOrder: { lt: target.stepOrder } },
|
||||
],
|
||||
@@ -576,7 +600,7 @@ export class TasksService {
|
||||
AND: [
|
||||
sameSentAt,
|
||||
{ labCaseId: target.labCaseId },
|
||||
{ treatmentDetailId: target.treatmentDetailId },
|
||||
{ sourceKey: target.sourceKey },
|
||||
{ prosthesisTypeCode: target.prosthesisTypeCode },
|
||||
{ stepOrder: target.stepOrder },
|
||||
{ id: { lt: target.id } },
|
||||
@@ -586,8 +610,16 @@ export class TasksService {
|
||||
|
||||
const sentAtBefore: Prisma.LabCaseTaskWhereInput =
|
||||
dir === 'desc'
|
||||
? { labCase: { sentAt: { gt: sentAt } } }
|
||||
: { labCase: { sentAt: { lt: sentAt } } };
|
||||
? {
|
||||
labCase: {
|
||||
OR: [{ sentAt: { gt: sentAt } }, { startedAt: { gt: sentAt } }],
|
||||
},
|
||||
}
|
||||
: {
|
||||
labCase: {
|
||||
OR: [{ sentAt: { lt: sentAt } }, { startedAt: { lt: sentAt } }],
|
||||
},
|
||||
};
|
||||
|
||||
return this.prisma.labCaseTask.count({
|
||||
where: {
|
||||
@@ -596,21 +628,49 @@ export class TasksService {
|
||||
});
|
||||
}
|
||||
|
||||
private buildSearchWhere(q: string): Prisma.TreatmentWhereInput {
|
||||
const orConditions: Prisma.PatientWhereInput[] = [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
private visibleStartedToLabWhere(labOrganizationId: string): Prisma.LabCaseWhereInput {
|
||||
return {
|
||||
OR: [
|
||||
{
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
{
|
||||
origin: LabCaseOrigin.LAB_INTERNAL,
|
||||
destinationOrganizationId: labOrganizationId,
|
||||
startedAt: { not: null },
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
private buildTaskSearchWhere(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' } },
|
||||
},
|
||||
},
|
||||
{ referringClinicName: { contains: q, mode: 'insensitive' } },
|
||||
{ referringDentistName: { contains: q, mode: 'insensitive' } },
|
||||
{ patientDisplayName: { contains: q, mode: 'insensitive' } },
|
||||
{ patientDisplayMobile: { contains: q, mode: 'insensitive' } },
|
||||
];
|
||||
const normalized = normalizeMobile(q);
|
||||
if (normalized) {
|
||||
orConditions.push({ mobile: normalized });
|
||||
orConditions.push({ treatment: { patient: { mobile: normalized } } });
|
||||
}
|
||||
return {
|
||||
OR: [
|
||||
{ patient: { OR: orConditions } },
|
||||
{ organization: { name: { contains: q, mode: 'insensitive' } } },
|
||||
],
|
||||
};
|
||||
return { OR: orConditions };
|
||||
}
|
||||
|
||||
private buildOrderBy(query: ListLabTasksDto): Prisma.LabCaseTaskOrderByWithRelationInput[] {
|
||||
@@ -628,6 +688,7 @@ export class TasksService {
|
||||
break;
|
||||
case 'clinic':
|
||||
orderBy = [
|
||||
{ labCase: { referringClinicName: dir } },
|
||||
{ labCase: { treatment: { organization: { name: dir } } } },
|
||||
{ createdAt: 'desc' },
|
||||
...stepTiebreakers,
|
||||
@@ -635,6 +696,7 @@ export class TasksService {
|
||||
break;
|
||||
case 'patient':
|
||||
orderBy = [
|
||||
{ labCase: { patientDisplayName: dir } },
|
||||
{ labCase: { treatment: { patient: { lastName: dir } } } },
|
||||
{ labCase: { treatment: { patient: { firstName: dir } } } },
|
||||
...stepTiebreakers,
|
||||
@@ -661,9 +723,10 @@ export class TasksService {
|
||||
case 'dueDate':
|
||||
orderBy = [
|
||||
{ labCase: { dueDate: dir } },
|
||||
{ labCase: { startedAt: 'desc' } },
|
||||
{ labCase: { sentAt: 'desc' } },
|
||||
{ labCaseId: 'asc' },
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ sourceKey: 'asc' },
|
||||
{ prosthesisTypeCode: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
@@ -672,9 +735,10 @@ export class TasksService {
|
||||
case 'date':
|
||||
default:
|
||||
orderBy = [
|
||||
{ labCase: { startedAt: dir } },
|
||||
{ labCase: { sentAt: dir } },
|
||||
{ labCaseId: 'asc' },
|
||||
{ treatmentDetailId: 'asc' },
|
||||
{ sourceKey: 'asc' },
|
||||
{ prosthesisTypeCode: 'asc' },
|
||||
{ stepOrder: 'asc' },
|
||||
{ id: 'asc' },
|
||||
@@ -693,10 +757,24 @@ export class TasksService {
|
||||
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
|
||||
prosthesisLabels: Map<string, string>,
|
||||
) {
|
||||
const clinic =
|
||||
task.labCase.treatment?.organization ??
|
||||
task.labCase.partnerClinic ?? {
|
||||
id: '',
|
||||
name: task.labCase.referringClinicName?.trim() || '',
|
||||
};
|
||||
const displayName = task.labCase.patientDisplayName?.trim() || '';
|
||||
const [firstName, ...rest] = displayName.split(/\s+/);
|
||||
const patient = task.labCase.treatment?.patient ?? {
|
||||
id: '',
|
||||
firstName: firstName || displayName || clinic.name || '—',
|
||||
lastName: rest.join(' '),
|
||||
};
|
||||
|
||||
return {
|
||||
id: task.id,
|
||||
labCaseId: task.labCaseId,
|
||||
treatmentDetailId: task.treatmentDetailId,
|
||||
treatmentDetailId: task.treatmentDetailId ?? task.sourceKey,
|
||||
teeth: normalizeTaskTeeth(task.teeth),
|
||||
treatmentType: task.treatmentType,
|
||||
prosthesisTypeCode: task.prosthesisTypeCode,
|
||||
@@ -718,13 +796,9 @@ export class TasksService {
|
||||
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
||||
: null,
|
||||
createdAt: task.createdAt.toISOString(),
|
||||
caseSentAt: task.labCase.sentAt?.toISOString() ?? null,
|
||||
clinic: task.labCase.treatment.organization,
|
||||
patient: {
|
||||
id: task.labCase.treatment.patient.id,
|
||||
firstName: task.labCase.treatment.patient.firstName,
|
||||
lastName: task.labCase.treatment.patient.lastName,
|
||||
},
|
||||
caseSentAt: (task.labCase.sentAt ?? task.labCase.startedAt)?.toISOString() ?? null,
|
||||
clinic,
|
||||
patient,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -1243,6 +1243,7 @@ export class TodayService {
|
||||
},
|
||||
select: {
|
||||
destinationOrganizationId: true,
|
||||
partnerClinicOrganizationId: true,
|
||||
tasks: { select: { status: true } },
|
||||
treatment: { select: { organizationId: true } },
|
||||
},
|
||||
@@ -1254,7 +1255,7 @@ export class TodayService {
|
||||
const partnerId =
|
||||
orgType === 'CLINIC'
|
||||
? labCase.destinationOrganizationId
|
||||
: labCase.treatment.organizationId;
|
||||
: labCase.treatment?.organizationId ?? labCase.partnerClinicOrganizationId;
|
||||
if (!partnerId) continue;
|
||||
|
||||
const isCompleted =
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
ArrayMinSize,
|
||||
IsArray,
|
||||
IsBoolean,
|
||||
IsDateString,
|
||||
IsIn,
|
||||
IsOptional,
|
||||
@@ -133,3 +134,24 @@ export class ListPatientTreatmentHistoryDto {
|
||||
@IsOptional()
|
||||
limit?: number;
|
||||
}
|
||||
|
||||
export class CreateStandaloneTreatmentDto {
|
||||
@IsOptional()
|
||||
@IsUUID()
|
||||
patientId?: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsBoolean()
|
||||
walkIn?: boolean;
|
||||
|
||||
@IsDateString()
|
||||
treatmentAt: string;
|
||||
}
|
||||
|
||||
export class ListDayTreatmentsDto {
|
||||
@IsDateString()
|
||||
from: string;
|
||||
|
||||
@IsDateString()
|
||||
to: string;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
ParseIntPipe,
|
||||
@@ -24,6 +25,8 @@ import {
|
||||
SaveTreatmentDraftDto,
|
||||
SaveTreatmentLabCasesDto,
|
||||
UpdateLabCaseDueDateDto,
|
||||
CreateStandaloneTreatmentDto,
|
||||
ListDayTreatmentsDto,
|
||||
} from './dto/treatment.dto';
|
||||
import { CreateLabCaseCommentDto } from '../lab-case-comments/dto/lab-case-comment.dto';
|
||||
import { LabCaseCommentsService } from '../lab-case-comments/lab-case-comments.service';
|
||||
@@ -83,6 +86,31 @@ export class TreatmentsController {
|
||||
);
|
||||
}
|
||||
|
||||
@Get('day')
|
||||
@ApiOperation({ summary: 'Standalone (no appointment) treatments for a local day range' })
|
||||
listDayStandalone(
|
||||
@Query() query: ListDayTreatmentsDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.listDayStandalone(
|
||||
query.from,
|
||||
query.to,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post()
|
||||
@ApiOperation({ summary: 'Create a treatment without an appointment (TAB_TREATMENT_EDIT)' })
|
||||
createStandalone(
|
||||
@Body() dto: CreateStandaloneTreatmentDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.createStandalone(dto, organizationId, req.user.id);
|
||||
}
|
||||
|
||||
@Get('appointments/:appointmentId/draft')
|
||||
@ApiOperation({ summary: 'Get draft treatment for an appointment (TAB_TREATMENT_READ)' })
|
||||
getDraft(
|
||||
@@ -268,4 +296,84 @@ export class TreatmentsController {
|
||||
dto,
|
||||
);
|
||||
}
|
||||
|
||||
@Get(':treatmentId/draft')
|
||||
@ApiOperation({ summary: 'Get a treatment draft by treatment id (TAB_TREATMENT_READ)' })
|
||||
getDraftByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.getDraftForTreatment(
|
||||
treatmentId,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':treatmentId/draft')
|
||||
@ApiOperation({ summary: 'Save draft treatment details by treatment id (TAB_TREATMENT_EDIT)' })
|
||||
saveDraftByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Body() dto: SaveTreatmentDraftDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveDraftForTreatment(
|
||||
treatmentId,
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Put(':treatmentId/lab-cases')
|
||||
@ApiOperation({ summary: 'Save lab case groupings by treatment id (TAB_TREATMENT_EDIT)' })
|
||||
saveLabCasesByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Body() dto: SaveTreatmentLabCasesDto,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.saveLabCasesForTreatment(
|
||||
treatmentId,
|
||||
dto,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Post(':treatmentId/details/:detailClientKey/attachments')
|
||||
@ApiOperation({ summary: 'Upload attachments for a standalone treatment detail' })
|
||||
@ApiConsumes('multipart/form-data')
|
||||
@UseInterceptors(
|
||||
FilesInterceptor('files', 20, {
|
||||
storage: memoryStorage(),
|
||||
}),
|
||||
)
|
||||
uploadDetailAttachmentsByTreatment(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Param('detailClientKey') detailClientKey: string,
|
||||
@UploadedFiles() files: Express.Multer.File[],
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.uploadDetailAttachmentsForTreatment(
|
||||
treatmentId,
|
||||
detailClientKey,
|
||||
files,
|
||||
organizationId,
|
||||
req.user.id,
|
||||
);
|
||||
}
|
||||
|
||||
@Delete(':treatmentId')
|
||||
@ApiOperation({ summary: 'Delete an empty standalone treatment (no details)' })
|
||||
deleteStandalone(
|
||||
@Param('treatmentId') treatmentId: string,
|
||||
@Req() req: { user: { id: string; organizationId?: string } },
|
||||
) {
|
||||
const organizationId = this.treatmentsService.getOrganizationIdFromUser(req.user);
|
||||
return this.treatmentsService.deleteStandalone(treatmentId, organizationId, req.user.id);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
SaveTreatmentDraftDto,
|
||||
SaveTreatmentLabCasesDto,
|
||||
UpdateLabCaseDueDateDto,
|
||||
CreateStandaloneTreatmentDto,
|
||||
} from './dto/treatment.dto';
|
||||
import {
|
||||
isLabCaseFullyCompleted,
|
||||
@@ -36,6 +37,7 @@ import {
|
||||
isActorTreatmentProvider,
|
||||
treatmentProviderScopeWhere,
|
||||
} from '../../common/treatment-provider-scope';
|
||||
import { ensureWalkInPatient } from '../../common/walk-in-patient';
|
||||
|
||||
const sentLabCaseInclude = {
|
||||
treatment: {
|
||||
@@ -67,6 +69,9 @@ const sentLabCaseInclude = {
|
||||
type SentLabCaseRow = Prisma.LabCaseGetPayload<{ include: typeof sentLabCaseInclude }>;
|
||||
|
||||
const treatmentInclude = {
|
||||
patient: {
|
||||
select: { id: true, firstName: true, lastName: true, isWalkIn: true },
|
||||
},
|
||||
details: {
|
||||
orderBy: [{ sortOrder: 'asc' as const }],
|
||||
include: {
|
||||
@@ -192,6 +197,108 @@ export class TreatmentsService {
|
||||
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
|
||||
}
|
||||
|
||||
async createStandalone(
|
||||
dto: CreateStandaloneTreatmentDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
|
||||
const walkIn = Boolean(dto.walkIn);
|
||||
if (!walkIn && !dto.patientId) {
|
||||
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const treatmentAt = new Date(dto.treatmentAt);
|
||||
if (Number.isNaN(treatmentAt.getTime())) {
|
||||
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
let patientId: string;
|
||||
if (walkIn) {
|
||||
const sentinel = await ensureWalkInPatient(this.prisma, organizationId);
|
||||
patientId = sentinel.id;
|
||||
} else {
|
||||
await this.ensurePatientExists(dto.patientId!);
|
||||
const patient = await this.prisma.patient.findUnique({
|
||||
where: { id: dto.patientId! },
|
||||
select: { isWalkIn: true },
|
||||
});
|
||||
if (patient?.isWalkIn) {
|
||||
throw new AppException(ErrorCode.TREATMENT_PATIENT_OR_WALK_IN, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
patientId = dto.patientId!;
|
||||
}
|
||||
|
||||
const treatment = await this.prisma.treatment.create({
|
||||
data: {
|
||||
organizationId,
|
||||
patientId,
|
||||
appointmentId: null,
|
||||
providerUserId: actorUserId,
|
||||
title: generateTreatmentTitle([]),
|
||||
treatmentAt,
|
||||
},
|
||||
include: treatmentInclude,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(treatment) };
|
||||
}
|
||||
|
||||
async deleteStandalone(
|
||||
treatmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
const treatment = await this.ensureTreatmentProvider(
|
||||
treatmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
);
|
||||
|
||||
if (treatment.appointmentId) {
|
||||
throw new AppException(ErrorCode.TREATMENT_NOT_STANDALONE, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const detailCount = await this.prisma.treatmentDetail.count({
|
||||
where: { treatmentId },
|
||||
});
|
||||
if (detailCount > 0) {
|
||||
throw new AppException(ErrorCode.TREATMENT_HAS_DETAILS, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
await this.prisma.treatment.delete({ where: { id: treatmentId } });
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async listDayStandalone(
|
||||
fromIso: string,
|
||||
toIso: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanReadTreatment(actorUserId, organizationId);
|
||||
const from = new Date(fromIso);
|
||||
const to = new Date(toIso);
|
||||
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime()) || to <= from) {
|
||||
throw new AppException(ErrorCode.VALIDATION_INVALID_REQUEST, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const items = await this.prisma.treatment.findMany({
|
||||
where: {
|
||||
organizationId,
|
||||
appointmentId: null,
|
||||
treatmentAt: { gte: from, lt: to },
|
||||
...treatmentProviderScopeWhere(actorUserId),
|
||||
},
|
||||
include: treatmentInclude,
|
||||
orderBy: [{ treatmentAt: 'asc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return { success: true, data: items.map((t) => this.mapTreatment(t)) };
|
||||
}
|
||||
|
||||
async listPatientLabCases(
|
||||
patientId: string,
|
||||
organizationId: string,
|
||||
@@ -322,16 +429,20 @@ export class TreatmentsService {
|
||||
}))
|
||||
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
|
||||
|
||||
const patient = lc.treatment.patient;
|
||||
const treatment = lc.treatment;
|
||||
if (!treatment) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
const patient = 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(),
|
||||
treatmentId: treatment.id,
|
||||
appointmentId: treatment.appointmentId,
|
||||
treatmentAt: treatment.treatmentAt.toISOString(),
|
||||
detailClientId: detail?.clientKey ?? detailLink?.treatmentDetailId ?? '',
|
||||
teeth: detailTeeth,
|
||||
prosthesisGroups,
|
||||
@@ -385,6 +496,15 @@ export class TreatmentsService {
|
||||
return { success: true, data: treatment ? this.mapTreatment(treatment) : null };
|
||||
}
|
||||
|
||||
async getDraftForTreatment(
|
||||
treatmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
const treatment = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
return { success: true, data: this.mapTreatment(treatment) };
|
||||
}
|
||||
|
||||
async saveDraftForAppointment(
|
||||
appointmentId: string,
|
||||
dto: SaveTreatmentDraftDto,
|
||||
@@ -398,11 +518,78 @@ export class TreatmentsService {
|
||||
actorUserId,
|
||||
);
|
||||
|
||||
for (const d of dto.details) {
|
||||
const existing = await this.prisma.treatment.findFirst({
|
||||
where: { appointmentId: appointment.id, organizationId },
|
||||
select: { id: true },
|
||||
});
|
||||
|
||||
const treatmentId = existing
|
||||
? existing.id
|
||||
: (
|
||||
await this.prisma.treatment.create({
|
||||
data: {
|
||||
organizationId,
|
||||
patientId: appointment.patientId,
|
||||
appointmentId: appointment.id,
|
||||
providerUserId: actorUserId,
|
||||
title: generateTreatmentTitle([]),
|
||||
treatmentAt: appointment.startAt,
|
||||
},
|
||||
select: { id: true },
|
||||
})
|
||||
).id;
|
||||
|
||||
const saved = await this.persistDraftDetails({
|
||||
treatmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
dto,
|
||||
pendingAppointmentId: appointment.id,
|
||||
treatmentAt: appointment.startAt,
|
||||
patientId: appointment.patientId,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(saved) };
|
||||
}
|
||||
|
||||
async saveDraftForTreatment(
|
||||
treatmentId: string,
|
||||
dto: SaveTreatmentDraftDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
const existing = await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
|
||||
const saved = await this.persistDraftDetails({
|
||||
treatmentId,
|
||||
organizationId,
|
||||
actorUserId,
|
||||
dto,
|
||||
pendingAppointmentId: existing.appointmentId,
|
||||
pendingTreatmentId: existing.appointmentId ? null : existing.id,
|
||||
treatmentAt: existing.appointmentId ? existing.treatmentAt : existing.treatmentAt,
|
||||
patientId: existing.patientId,
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(saved) };
|
||||
}
|
||||
|
||||
private async persistDraftDetails(args: {
|
||||
treatmentId: string;
|
||||
organizationId: string;
|
||||
actorUserId: string;
|
||||
dto: SaveTreatmentDraftDto;
|
||||
pendingAppointmentId: string | null;
|
||||
pendingTreatmentId?: string | null;
|
||||
treatmentAt: Date;
|
||||
patientId: string;
|
||||
}) {
|
||||
for (const d of args.dto.details) {
|
||||
this.treatmentCatalog.assertKnownTreatmentType(d.treatmentType);
|
||||
}
|
||||
|
||||
const normalizedDetails = dto.details.map((d, index) => {
|
||||
const normalizedDetails = args.dto.details.map((d, index) => {
|
||||
const teeth = normalizeTeeth(d.teeth);
|
||||
const toothSelectionGroups = normalizeToothSelectionGroups(
|
||||
d.toothSelectionGroups,
|
||||
@@ -422,41 +609,23 @@ export class TreatmentsService {
|
||||
normalizedDetails.map((d) => ({ treatmentType: d.treatmentType, teeth: d.teeth })),
|
||||
);
|
||||
|
||||
const treatment = await this.prisma.$transaction(async (tx) => {
|
||||
const existing = await tx.treatment.findFirst({
|
||||
where: { appointmentId: appointment.id, organizationId },
|
||||
select: { id: true },
|
||||
return this.prisma.$transaction(async (tx) => {
|
||||
const saved = await tx.treatment.update({
|
||||
where: { id: args.treatmentId },
|
||||
data: {
|
||||
title,
|
||||
treatmentAt: args.treatmentAt,
|
||||
patientId: args.patientId,
|
||||
providerUserId: args.actorUserId,
|
||||
},
|
||||
});
|
||||
|
||||
const saved = existing
|
||||
? await tx.treatment.update({
|
||||
where: { id: existing.id },
|
||||
data: {
|
||||
title,
|
||||
treatmentAt: appointment.startAt,
|
||||
patientId: appointment.patientId,
|
||||
providerUserId: actorUserId,
|
||||
},
|
||||
})
|
||||
: await tx.treatment.create({
|
||||
data: {
|
||||
organizationId,
|
||||
patientId: appointment.patientId,
|
||||
appointmentId: appointment.id,
|
||||
providerUserId: actorUserId,
|
||||
title,
|
||||
treatmentAt: appointment.startAt,
|
||||
},
|
||||
});
|
||||
|
||||
const keepDetailIds = normalizedDetails.map((d) => d.id).filter(Boolean) as string[];
|
||||
|
||||
const existingDetails = existing
|
||||
? await tx.treatmentDetail.findMany({
|
||||
where: { treatmentId: saved.id },
|
||||
select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
|
||||
})
|
||||
: [];
|
||||
const existingDetails = await tx.treatmentDetail.findMany({
|
||||
where: { treatmentId: saved.id },
|
||||
select: { id: true, labCaseLink: { select: { labCase: { select: { sentAt: true } } } } },
|
||||
});
|
||||
|
||||
const lockedDetailIds = new Set(
|
||||
existingDetails
|
||||
@@ -504,11 +673,14 @@ export class TreatmentsService {
|
||||
});
|
||||
|
||||
const allowedAttachmentIds = new Set(d.attachmentIds);
|
||||
const pendingWhere: Prisma.TreatmentDetailAttachmentWhereInput = {
|
||||
detailClientKey: d.clientId,
|
||||
...(args.pendingAppointmentId
|
||||
? { appointmentId: args.pendingAppointmentId }
|
||||
: { treatmentId: args.pendingTreatmentId ?? saved.id }),
|
||||
};
|
||||
const pendingAttachments = await tx.treatmentDetailAttachment.findMany({
|
||||
where: {
|
||||
appointmentId: appointment.id,
|
||||
detailClientKey: d.clientId,
|
||||
},
|
||||
where: pendingWhere,
|
||||
});
|
||||
|
||||
for (const attachment of pendingAttachments) {
|
||||
@@ -517,7 +689,12 @@ export class TreatmentsService {
|
||||
} else {
|
||||
await tx.treatmentDetailAttachment.update({
|
||||
where: { id: attachment.id },
|
||||
data: { detailId: row.id, appointmentId: null, detailClientKey: null },
|
||||
data: {
|
||||
detailId: row.id,
|
||||
appointmentId: null,
|
||||
treatmentId: null,
|
||||
detailClientKey: null,
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -535,8 +712,6 @@ export class TreatmentsService {
|
||||
include: treatmentInclude,
|
||||
});
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTreatment(treatment) };
|
||||
}
|
||||
|
||||
async saveLabCasesForAppointment(
|
||||
@@ -561,6 +736,20 @@ export class TreatmentsService {
|
||||
throw new AppException(ErrorCode.TREATMENT_SAVE_DETAILS_BEFORE_LAB, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
return this.saveLabCasesForTreatment(treatment.id, dto, organizationId, actorUserId);
|
||||
}
|
||||
|
||||
async saveLabCasesForTreatment(
|
||||
treatmentId: string,
|
||||
dto: SaveTreatmentLabCasesDto,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
|
||||
const treatment = { id: treatmentId };
|
||||
|
||||
const detailIds = dto.labCases.map((lc) => lc.treatmentDetailId);
|
||||
const uniqueDetailIds = new Set(detailIds);
|
||||
if (uniqueDetailIds.size !== detailIds.length) {
|
||||
@@ -658,6 +847,7 @@ export class TreatmentsService {
|
||||
: await tx.labCase.create({
|
||||
data: {
|
||||
treatmentId: treatment.id,
|
||||
origin: 'CLINIC_DISPATCH',
|
||||
clientKey: lc.clientId,
|
||||
sortOrder: index,
|
||||
destinationOrganizationId: lc.destinationOrganizationId ?? null,
|
||||
@@ -679,6 +869,8 @@ export class TreatmentsService {
|
||||
data: lc.toothProsthesis.map((tp) => ({
|
||||
labCaseId: row.id,
|
||||
treatmentDetailId: tp.treatmentDetailId,
|
||||
sourceKey: tp.treatmentDetailId,
|
||||
treatmentType: detailById.get(tp.treatmentDetailId)?.treatmentType ?? 'prosthesis',
|
||||
tooth: tp.tooth,
|
||||
prosthesisTypeCode: tp.prosthesisTypeCode,
|
||||
selectionGroupId: tp.selectionGroupId?.trim() || '',
|
||||
@@ -763,7 +955,17 @@ export class TreatmentsService {
|
||||
throw new AppException(ErrorCode.TREATMENT_CASE_ONE_DETAIL, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
assertCompleteToothProsthesisMap(labCase);
|
||||
if (!labCase.treatment) {
|
||||
throw new AppException(ErrorCode.LAB_CASE_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
|
||||
assertCompleteToothProsthesisMap({
|
||||
details: labCase.details,
|
||||
toothProsthesis: labCase.toothProsthesis.filter(
|
||||
(row): row is typeof row & { treatmentDetailId: string } =>
|
||||
Boolean(row.treatmentDetailId),
|
||||
),
|
||||
});
|
||||
|
||||
if (!isActorTreatmentProvider(labCase.treatment, actorUserId)) {
|
||||
throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN);
|
||||
@@ -1011,6 +1213,72 @@ export class TreatmentsService {
|
||||
return { success: true, data: created };
|
||||
}
|
||||
|
||||
async uploadDetailAttachmentsForTreatment(
|
||||
treatmentId: string,
|
||||
detailClientKey: string,
|
||||
files: Express.Multer.File[],
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
await this.assertCanEditTreatment(actorUserId, organizationId);
|
||||
await this.ensureTreatmentProvider(treatmentId, organizationId, actorUserId);
|
||||
|
||||
if (!detailClientKey?.trim()) {
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAIL_KEY_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
if (!files?.length) {
|
||||
throw new AppException(ErrorCode.TREATMENT_FILE_REQUIRED, HttpStatus.BAD_REQUEST);
|
||||
}
|
||||
|
||||
const existingDetail = await this.prisma.treatmentDetail.findFirst({
|
||||
where: {
|
||||
clientKey: detailClientKey,
|
||||
treatmentId,
|
||||
},
|
||||
select: {
|
||||
labCaseLink: {
|
||||
select: { labCase: { select: { sentAt: true } } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (existingDetail?.labCaseLink?.labCase.sentAt) {
|
||||
throw new AppException(ErrorCode.TREATMENT_DETAIL_SENT, HttpStatus.CONFLICT);
|
||||
}
|
||||
|
||||
const orgDir = join(this.uploadRoot, organizationId);
|
||||
mkdirSync(orgDir, { recursive: true });
|
||||
|
||||
const created: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
mimeType: string;
|
||||
sizeBytes: number;
|
||||
}[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const storageName = `${randomUUID()}-${file.originalname.replace(/[^\w.\-()+]/g, '_')}`;
|
||||
const storagePath = join(orgDir, storageName);
|
||||
const { writeFileSync } = await import('fs');
|
||||
writeFileSync(storagePath, file.buffer);
|
||||
|
||||
const attachment = await this.prisma.treatmentDetailAttachment.create({
|
||||
data: {
|
||||
treatmentId,
|
||||
detailClientKey,
|
||||
fileName: file.originalname,
|
||||
mimeType: file.mimetype || 'application/octet-stream',
|
||||
sizeBytes: file.size,
|
||||
storagePath,
|
||||
},
|
||||
});
|
||||
|
||||
created.push(this.mapAttachment(attachment));
|
||||
}
|
||||
|
||||
return { success: true, data: created };
|
||||
}
|
||||
|
||||
async streamAttachmentFile(
|
||||
attachmentId: string,
|
||||
organizationId: string,
|
||||
@@ -1031,6 +1299,7 @@ export class TreatmentsService {
|
||||
},
|
||||
},
|
||||
{ appointmentId: { not: null } },
|
||||
{ treatmentId: { not: null } },
|
||||
],
|
||||
},
|
||||
include: {
|
||||
@@ -1060,6 +1329,20 @@ export class TreatmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (!attachment.detail && attachment.treatmentId) {
|
||||
const treatment = await this.prisma.treatment.findFirst({
|
||||
where: {
|
||||
id: attachment.treatmentId,
|
||||
organizationId,
|
||||
providerUserId: actorUserId,
|
||||
},
|
||||
select: { id: true },
|
||||
});
|
||||
if (!treatment) {
|
||||
throw new AppException(ErrorCode.TREATMENT_ATTACHMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
}
|
||||
|
||||
if (!existsSync(attachment.storagePath)) {
|
||||
throw new AppException(ErrorCode.TREATMENT_FILE_UNAVAILABLE, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
@@ -1075,8 +1358,15 @@ export class TreatmentsService {
|
||||
id: string;
|
||||
patientId: string;
|
||||
appointmentId: string | null;
|
||||
providerUserId?: string;
|
||||
title: string;
|
||||
treatmentAt: Date;
|
||||
patient?: {
|
||||
id: string;
|
||||
firstName: string;
|
||||
lastName: string;
|
||||
isWalkIn: boolean;
|
||||
};
|
||||
details: Array<{
|
||||
id: string;
|
||||
clientKey: string | null;
|
||||
@@ -1127,8 +1417,17 @@ export class TreatmentsService {
|
||||
id: treatment.id,
|
||||
patientId: treatment.patientId,
|
||||
appointmentId: treatment.appointmentId,
|
||||
providerUserId: treatment.providerUserId ?? null,
|
||||
title: treatment.title,
|
||||
treatmentAt: treatment.treatmentAt.toISOString(),
|
||||
patient: treatment.patient
|
||||
? {
|
||||
id: treatment.patient.id,
|
||||
firstName: treatment.patient.firstName,
|
||||
lastName: treatment.patient.lastName,
|
||||
isWalkIn: treatment.patient.isWalkIn,
|
||||
}
|
||||
: null,
|
||||
details: treatment.details.map((d) => this.mapDetail(d)),
|
||||
labCases: treatment.labCases.map((lc) => this.mapLabCase(lc)),
|
||||
documents,
|
||||
@@ -1209,7 +1508,7 @@ export class TreatmentsService {
|
||||
organization?: { id: string; name: string };
|
||||
}>;
|
||||
toothProsthesis?: Array<{
|
||||
treatmentDetailId: string;
|
||||
treatmentDetailId: string | null;
|
||||
tooth: string;
|
||||
prosthesisTypeCode: string;
|
||||
selectionGroupId?: string;
|
||||
@@ -1319,6 +1618,24 @@ export class TreatmentsService {
|
||||
}
|
||||
}
|
||||
|
||||
private async ensureTreatmentProvider(
|
||||
treatmentId: string,
|
||||
organizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
const treatment = await this.prisma.treatment.findFirst({
|
||||
where: { id: treatmentId, organizationId },
|
||||
include: treatmentInclude,
|
||||
});
|
||||
if (!treatment) {
|
||||
throw new AppException(ErrorCode.TREATMENT_NOT_FOUND, HttpStatus.NOT_FOUND);
|
||||
}
|
||||
if (!isActorTreatmentProvider(treatment, actorUserId)) {
|
||||
throw new AppException(ErrorCode.TREATMENT_ONLY_PROVIDER_SEND, HttpStatus.FORBIDDEN);
|
||||
}
|
||||
return treatment;
|
||||
}
|
||||
|
||||
private async ensureAppointmentProvider(
|
||||
appointmentId: string,
|
||||
organizationId: string,
|
||||
|
||||
Reference in New Issue
Block a user