2026-06-28 16:46:42 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
ForbiddenException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
2026-07-07 18:43:10 +03:30
|
|
|
import { createReadStream, existsSync } from 'fs';
|
2026-07-13 17:44:44 +03:30
|
|
|
import { CatalogEntityKind, LabCaseActivityType, LabTaskStatus, Prisma } from '@prisma/client';
|
2026-06-28 16:46:42 +03:30
|
|
|
import { PrismaService } from '../../../prisma/prisma.service';
|
|
|
|
|
import { normalizeMobile } from '../../common/phone';
|
2026-07-06 20:40:19 +03:30
|
|
|
import {
|
|
|
|
|
CatalogLabelService,
|
|
|
|
|
normalizeCatalogLocale,
|
|
|
|
|
} from '../catalog/catalog-label.service';
|
2026-07-14 03:06:56 +03:30
|
|
|
import { ProsthesisCatalogService } from '../prosthesis-catalog/prosthesis-catalog.service';
|
2026-06-28 16:46:42 +03:30
|
|
|
import { normalizeTeeth } from '../treatments/treatment.utils';
|
2026-07-13 15:47:32 +03:30
|
|
|
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
|
2026-07-13 16:30:36 +03:30
|
|
|
import {
|
|
|
|
|
isLabCaseOverdue,
|
|
|
|
|
} from '../../common/lab-case-due-date';
|
2026-07-07 15:31:09 +03:30
|
|
|
import { normalizeTaskTeeth } from './lab-case-task.util';
|
2026-07-13 15:47:32 +03:30
|
|
|
import { hasEffectivePermission } from '../../common/membership-permissions';
|
2026-07-13 17:44:44 +03:30
|
|
|
import { LAB_CASES_TAB_ACTIVITY_TYPES } from '../../common/lab-case-activity';
|
|
|
|
|
import { LabCaseActivityService } from '../notifications/lab-case-activity.service';
|
2026-07-14 21:07:05 +03:30
|
|
|
import { LabCaseAccessService } from './lab-case-access.service';
|
2026-06-28 16:46:42 +03:30
|
|
|
|
|
|
|
|
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: [
|
2026-07-07 15:31:09 +03:30
|
|
|
{ treatmentDetailId: 'asc' as const },
|
|
|
|
|
{ prosthesisTypeCode: 'asc' as const },
|
2026-06-28 16:46:42 +03:30
|
|
|
{ stepOrder: 'asc' as const },
|
|
|
|
|
],
|
|
|
|
|
include: {
|
2026-07-07 15:31:09 +03:30
|
|
|
lastStatusChangedBy: { select: { id: true, name: true } },
|
2026-07-13 15:47:32 +03:30
|
|
|
assignee: { select: { id: true, name: true } },
|
2026-07-07 15:31:09 +03:30
|
|
|
statusEvents: {
|
|
|
|
|
orderBy: { changedAt: 'asc' as const },
|
|
|
|
|
include: { changedBy: { select: { id: true, name: true } } },
|
|
|
|
|
},
|
2026-06-28 16:46:42 +03:30
|
|
|
},
|
|
|
|
|
},
|
2026-07-07 18:43:10 +03:30
|
|
|
toothProsthesis: true,
|
|
|
|
|
attachments: {
|
|
|
|
|
include: {
|
|
|
|
|
attachment: {
|
|
|
|
|
select: {
|
|
|
|
|
id: true,
|
|
|
|
|
fileName: true,
|
|
|
|
|
mimeType: true,
|
|
|
|
|
sizeBytes: true,
|
|
|
|
|
createdAt: true,
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-06-28 16:46:42 +03:30
|
|
|
} satisfies Prisma.LabCaseInclude;
|
|
|
|
|
|
2026-07-07 15:31:09 +03:30
|
|
|
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
|
|
|
|
|
include: {
|
|
|
|
|
lastStatusChangedBy: { select: { id: true; name: true } };
|
2026-07-13 15:47:32 +03:30
|
|
|
assignee: { select: { id: true; name: true } };
|
2026-07-07 15:31:09 +03:30
|
|
|
statusEvents: {
|
|
|
|
|
include: { changedBy: { select: { id: true; name: true } } };
|
|
|
|
|
};
|
|
|
|
|
};
|
|
|
|
|
}>;
|
|
|
|
|
|
2026-06-28 16:46:42 +03:30
|
|
|
@Injectable()
|
|
|
|
|
export class CasesService {
|
|
|
|
|
constructor(
|
|
|
|
|
private readonly prisma: PrismaService,
|
2026-07-14 03:06:56 +03:30
|
|
|
private readonly prosthesisCatalog: ProsthesisCatalogService,
|
2026-07-06 20:40:19 +03:30
|
|
|
private readonly catalogLabels: CatalogLabelService,
|
2026-07-13 17:44:44 +03:30
|
|
|
private readonly labCaseActivity: LabCaseActivityService,
|
2026-07-14 21:07:05 +03:30
|
|
|
private readonly labCaseAccess: LabCaseAccessService,
|
2026-06-28 16:46:42 +03:30
|
|
|
) {}
|
|
|
|
|
|
|
|
|
|
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);
|
|
|
|
|
|
2026-07-14 03:06:56 +03:30
|
|
|
if (query.prosthesisTypeCode) {
|
|
|
|
|
this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode);
|
2026-06-28 16:46:42 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const page = query.page ?? 1;
|
|
|
|
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
|
|
|
|
const skip = (page - 1) * limit;
|
|
|
|
|
|
2026-06-28 17:49:40 +03:30
|
|
|
const where = this.buildListWhere(labOrganizationId, query);
|
2026-06-28 16:46:42 +03:30
|
|
|
|
|
|
|
|
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 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-14 03:06:56 +03:30
|
|
|
tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } },
|
2026-06-28 16:46:42 +03:30
|
|
|
},
|
|
|
|
|
orderBy: [{ sentAt: 'desc' }],
|
|
|
|
|
skip,
|
|
|
|
|
take: limit,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.labCase.count({ where }),
|
|
|
|
|
]);
|
|
|
|
|
|
2026-07-13 17:44:44 +03:30
|
|
|
const unreadCaseIds = await this.labCaseActivity.unreadCaseIdsInBatch(
|
|
|
|
|
actorUserId,
|
|
|
|
|
labOrganizationId,
|
|
|
|
|
items.map((lc) => lc.id),
|
|
|
|
|
LAB_CASES_TAB_ACTIVITY_TYPES,
|
|
|
|
|
'LAB',
|
|
|
|
|
);
|
|
|
|
|
|
2026-06-28 16:46:42 +03:30
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
2026-07-13 17:44:44 +03:30
|
|
|
items: items.map((lc) => ({
|
|
|
|
|
...this.mapLabCaseListItem(lc),
|
|
|
|
|
hasUnread: unreadCaseIds.has(lc.id),
|
|
|
|
|
})),
|
2026-06-28 16:46:42 +03:30
|
|
|
pagination: {
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
total,
|
|
|
|
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:49:40 +03:30
|
|
|
async listFilterOptions(labOrganizationId: string, actorUserId: string) {
|
|
|
|
|
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
|
|
|
|
|
2026-07-14 03:06:56 +03:30
|
|
|
const [clinicRows, taskRows] = await Promise.all([
|
|
|
|
|
this.prisma.labCase.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
sentAt: { not: null },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
treatment: {
|
|
|
|
|
select: {
|
|
|
|
|
organization: { select: { id: true, name: true } },
|
|
|
|
|
},
|
2026-06-28 17:49:40 +03:30
|
|
|
},
|
|
|
|
|
},
|
2026-07-14 03:06:56 +03:30
|
|
|
}),
|
|
|
|
|
this.prisma.labCaseTask.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
labCase: {
|
|
|
|
|
sentAt: { not: null },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
|
|
|
|
},
|
2026-06-28 17:49:40 +03:30
|
|
|
},
|
2026-07-14 03:06:56 +03:30
|
|
|
select: { prosthesisTypeCode: true },
|
|
|
|
|
distinct: ['prosthesisTypeCode'],
|
|
|
|
|
}),
|
|
|
|
|
]);
|
2026-06-28 17:49:40 +03:30
|
|
|
|
|
|
|
|
const clinicsById = new Map<string, { id: string; name: string }>();
|
2026-07-14 03:06:56 +03:30
|
|
|
for (const row of clinicRows) {
|
2026-06-28 17:49:40 +03:30
|
|
|
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 03:06:56 +03:30
|
|
|
const typeCodes = new Set(taskRows.map((row) => row.prosthesisTypeCode));
|
|
|
|
|
const catalog = await this.prosthesisCatalog.list();
|
|
|
|
|
const prosthesisTypes = catalog
|
|
|
|
|
.filter((entry) => typeCodes.has(entry.code))
|
|
|
|
|
.map((entry) => ({ code: entry.code }));
|
2026-06-28 17:49:40 +03:30
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
2026-07-14 03:06:56 +03:30
|
|
|
prosthesisTypes,
|
2026-06-28 17:49:40 +03:30
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 23:19:33 +03:30
|
|
|
/** Cases exchanged between one clinic and one lab (Organizations connection history). */
|
|
|
|
|
async listBetweenOrganizations(
|
|
|
|
|
clinicOrganizationId: string,
|
|
|
|
|
labOrganizationId: string,
|
|
|
|
|
query: ListLabCasesDto,
|
|
|
|
|
) {
|
2026-07-14 03:06:56 +03:30
|
|
|
if (query.prosthesisTypeCode) {
|
|
|
|
|
this.prosthesisCatalog.assertKnownProsthesisType(query.prosthesisTypeCode);
|
2026-06-28 23:19:33 +03:30
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
2026-07-14 03:06:56 +03:30
|
|
|
tasks: { select: { id: true, status: true, prosthesisTypeCode: true, teeth: true } },
|
2026-06-28 23:19:33 +03:30
|
|
|
},
|
|
|
|
|
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,
|
2026-07-06 20:40:19 +03:30
|
|
|
localeInput?: string | null,
|
2026-06-28 23:19:33 +03:30
|
|
|
) {
|
|
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
data: await this.mapLabCaseDetail(labCase, localeInput),
|
|
|
|
|
};
|
2026-06-28 23:19:33 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
async getOne(
|
|
|
|
|
labCaseId: string,
|
|
|
|
|
labOrganizationId: string,
|
|
|
|
|
actorUserId: string,
|
|
|
|
|
localeInput?: string | null,
|
|
|
|
|
) {
|
2026-06-28 16:46:42 +03:30
|
|
|
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');
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
2026-06-28 16:46:42 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-07 18:43:10 +03:30
|
|
|
async streamCaseAttachment(
|
|
|
|
|
labCaseId: string,
|
|
|
|
|
attachmentId: string,
|
|
|
|
|
labOrganizationId: string,
|
|
|
|
|
actorUserId: string,
|
|
|
|
|
) {
|
|
|
|
|
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
|
|
|
|
|
|
|
|
|
const link = await this.prisma.labCaseAttachment.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
labCaseId,
|
|
|
|
|
attachmentId,
|
|
|
|
|
labCase: {
|
|
|
|
|
sentAt: { not: null },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
attachment: { select: { storagePath: true, fileName: true, mimeType: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!link?.attachment) {
|
|
|
|
|
throw new NotFoundException('Attachment not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if (!existsSync(link.attachment.storagePath)) {
|
|
|
|
|
throw new NotFoundException('Attachment file is missing on disk');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
stream: createReadStream(link.attachment.storagePath),
|
|
|
|
|
fileName: link.attachment.fileName,
|
|
|
|
|
mimeType: link.attachment.mimeType,
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
async setCaseImportant(
|
2026-06-28 16:46:42 +03:30
|
|
|
labCaseId: string,
|
2026-07-08 02:53:47 +03:30
|
|
|
dto: UpdateLabCaseImportantDto,
|
2026-06-28 16:46:42 +03:30
|
|
|
labOrganizationId: string,
|
|
|
|
|
actorUserId: string,
|
2026-07-06 20:40:19 +03:30
|
|
|
localeInput?: string | null,
|
2026-06-28 16:46:42 +03:30
|
|
|
) {
|
|
|
|
|
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
const existing = await this.prisma.labCase.findFirst({
|
2026-06-28 16:46:42 +03:30
|
|
|
where: {
|
2026-07-08 02:53:47 +03:30
|
|
|
id: labCaseId,
|
|
|
|
|
sentAt: { not: null },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
2026-06-28 16:46:42 +03:30
|
|
|
},
|
2026-07-13 17:44:44 +03:30
|
|
|
select: { id: true, isImportant: true },
|
2026-06-28 16:46:42 +03:30
|
|
|
});
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
if (!existing) {
|
|
|
|
|
throw new NotFoundException('Case not found');
|
2026-06-28 16:46:42 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
await this.prisma.labCase.update({
|
|
|
|
|
where: { id: labCaseId },
|
2026-07-07 15:31:09 +03:30
|
|
|
data: { isImportant: dto.isImportant },
|
2026-06-28 16:46:42 +03:30
|
|
|
});
|
|
|
|
|
|
2026-07-13 17:44:44 +03:30
|
|
|
if (dto.isImportant && !existing.isImportant) {
|
|
|
|
|
await this.labCaseActivity.record({
|
|
|
|
|
labCaseId,
|
|
|
|
|
type: LabCaseActivityType.CASE_IMPORTANT,
|
|
|
|
|
actorUserId,
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
|
|
|
|
where: { id: labCaseId },
|
|
|
|
|
include: labCaseListInclude,
|
|
|
|
|
});
|
2026-07-06 20:40:19 +03:30
|
|
|
|
2026-07-08 02:53:47 +03:30
|
|
|
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
2026-06-28 16:46:42 +03:30
|
|
|
}
|
|
|
|
|
|
2026-07-13 15:47:32 +03:30
|
|
|
async listAssignableStaff(labOrganizationId: string, actorUserId: string) {
|
|
|
|
|
await this.assertCanEditCases(actorUserId, labOrganizationId);
|
|
|
|
|
|
|
|
|
|
const memberships = await this.prisma.membership.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId: labOrganizationId,
|
|
|
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
permissions: { include: { permission: true } },
|
|
|
|
|
organization: { include: { type: true, plan: true } },
|
|
|
|
|
user: { select: { id: true, name: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const staff = memberships
|
|
|
|
|
.filter((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'))
|
|
|
|
|
.map((m) => ({
|
|
|
|
|
id: m.user.id,
|
|
|
|
|
name: m.user.name,
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
|
|
|
|
|
|
|
|
return { success: true, data: staff };
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async assignTask(
|
|
|
|
|
labCaseId: string,
|
|
|
|
|
taskId: string,
|
|
|
|
|
dto: AssignLabCaseTaskDto,
|
|
|
|
|
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 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
select: { id: true },
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!task) {
|
|
|
|
|
throw new NotFoundException('Task not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const assigneeUserId = dto.assigneeUserId ?? null;
|
|
|
|
|
|
|
|
|
|
if (assigneeUserId) {
|
|
|
|
|
const memberships = await this.prisma.membership.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
organizationId: labOrganizationId,
|
|
|
|
|
userId: assigneeUserId,
|
|
|
|
|
OR: [{ isOwner: true }, { isActive: true }],
|
|
|
|
|
},
|
|
|
|
|
include: {
|
|
|
|
|
permissions: { include: { permission: true } },
|
|
|
|
|
organization: { include: { type: true, plan: true } },
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const canReceive = memberships.some((m) => hasEffectivePermission(m, 'TAB_TASKS_EDIT'));
|
|
|
|
|
if (!canReceive) {
|
|
|
|
|
throw new BadRequestException('Selected user cannot be assigned tasks');
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
await this.prisma.labCaseTask.update({
|
|
|
|
|
where: { id: taskId },
|
|
|
|
|
data: {
|
|
|
|
|
assigneeUserId,
|
|
|
|
|
assignedAt: assigneeUserId ? new Date() : null,
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const labCase = await this.prisma.labCase.findFirstOrThrow({
|
|
|
|
|
where: { id: labCaseId },
|
|
|
|
|
include: labCaseListInclude,
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:49:40 +03:30
|
|
|
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 } }
|
|
|
|
|
: {}),
|
2026-07-14 03:06:56 +03:30
|
|
|
...(query.prosthesisTypeCode
|
2026-06-28 17:49:40 +03:30
|
|
|
? {
|
2026-07-14 03:06:56 +03:30
|
|
|
tasks: {
|
|
|
|
|
some: { prosthesisTypeCode: query.prosthesisTypeCode },
|
2026-06-28 17:49:40 +03:30
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
...(query.q?.trim() ? this.buildSearchWhere(query.q.trim()) : {}),
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 16:46:42 +03:30
|
|
|
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;
|
2026-07-13 16:30:36 +03:30
|
|
|
dueDate: Date | null;
|
2026-07-13 13:22:38 +03:30
|
|
|
isImportant: boolean;
|
2026-06-28 16:46:42 +03:30
|
|
|
treatment: {
|
|
|
|
|
organization: { id: string; name: string };
|
|
|
|
|
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
|
|
|
|
};
|
2026-07-14 03:06:56 +03:30
|
|
|
tasks: Array<{
|
|
|
|
|
id: string;
|
|
|
|
|
status: LabTaskStatus;
|
|
|
|
|
prosthesisTypeCode: string;
|
|
|
|
|
teeth: Prisma.JsonValue;
|
|
|
|
|
}>;
|
2026-06-28 16:46:42 +03:30
|
|
|
}) {
|
2026-07-14 03:06:56 +03:30
|
|
|
const prosthesisGroups = this.buildProsthesisGroupsFromTasks(lc.tasks);
|
2026-06-28 16:46:42 +03:30
|
|
|
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: lc.id,
|
|
|
|
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
2026-07-13 16:30:36 +03:30
|
|
|
dueDate: lc.dueDate?.toISOString() ?? null,
|
|
|
|
|
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
2026-07-13 13:22:38 +03:30
|
|
|
isImportant: lc.isImportant,
|
2026-06-28 16:46:42 +03:30
|
|
|
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,
|
|
|
|
|
},
|
2026-07-14 03:06:56 +03:30
|
|
|
prosthesisGroups,
|
2026-06-28 16:46:42 +03:30
|
|
|
taskProgress: {
|
|
|
|
|
completed: completedTasks,
|
|
|
|
|
total: lc.tasks.length,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
private async mapLabCaseDetail(
|
|
|
|
|
lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>,
|
|
|
|
|
localeInput?: string | null,
|
|
|
|
|
) {
|
|
|
|
|
const locale = normalizeCatalogLocale(localeInput);
|
2026-07-10 15:26:36 +03:30
|
|
|
const treatmentType = lc.details[0]?.detail.treatmentType ?? null;
|
|
|
|
|
const link = lc.details[0];
|
2026-07-06 20:40:19 +03:30
|
|
|
const prosthesisCodes = [...new Set(lc.tasks.map((t) => t.prosthesisTypeCode).filter(Boolean))];
|
|
|
|
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
|
|
|
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
|
|
|
|
prosthesisCodes,
|
|
|
|
|
locale,
|
|
|
|
|
);
|
2026-07-07 15:31:09 +03:30
|
|
|
const tasksByTooth = this.groupTasks(lc.tasks, prosthesisLabels);
|
2026-07-14 21:07:05 +03:30
|
|
|
const accessToken = lc.sentAt
|
|
|
|
|
? await this.labCaseAccess.ensureAccessToken(lc.id)
|
|
|
|
|
: null;
|
|
|
|
|
const shareUrl = accessToken
|
|
|
|
|
? this.labCaseAccess.buildShareUrl(accessToken, locale)
|
|
|
|
|
: null;
|
2026-06-28 16:46:42 +03:30
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: lc.id,
|
|
|
|
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
2026-07-13 16:30:36 +03:30
|
|
|
dueDate: lc.dueDate?.toISOString() ?? null,
|
|
|
|
|
isOverdue: isLabCaseOverdue(lc.dueDate, lc.tasks),
|
2026-07-08 02:53:47 +03:30
|
|
|
isImportant: lc.isImportant,
|
2026-07-14 21:07:05 +03:30
|
|
|
shareUrl,
|
2026-06-28 16:46:42 +03:30
|
|
|
clinic: lc.treatment.organization,
|
|
|
|
|
patient: lc.treatment.patient,
|
|
|
|
|
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
2026-07-10 15:26:36 +03:30
|
|
|
treatmentType,
|
|
|
|
|
detail: link
|
|
|
|
|
? {
|
|
|
|
|
id: link.detail.id,
|
|
|
|
|
treatmentType: link.detail.treatmentType,
|
|
|
|
|
teeth: normalizeTeeth(link.detail.teeth),
|
|
|
|
|
comment: link.detail.comment,
|
|
|
|
|
}
|
|
|
|
|
: null,
|
2026-07-07 18:43:10 +03:30
|
|
|
toothProsthesis: lc.toothProsthesis.map((row) => ({
|
|
|
|
|
treatmentDetailId: row.treatmentDetailId,
|
|
|
|
|
tooth: row.tooth,
|
|
|
|
|
prosthesisTypeCode: row.prosthesisTypeCode,
|
|
|
|
|
})),
|
|
|
|
|
attachments: lc.attachments.map((row) => ({
|
|
|
|
|
id: row.attachment.id,
|
|
|
|
|
fileName: row.attachment.fileName,
|
|
|
|
|
mimeType: row.attachment.mimeType,
|
|
|
|
|
sizeBytes: row.attachment.sizeBytes,
|
|
|
|
|
createdAt: row.attachment.createdAt.toISOString(),
|
|
|
|
|
})),
|
2026-06-28 16:46:42 +03:30
|
|
|
sends: lc.sends.map((s) => ({
|
|
|
|
|
organizationId: s.organizationId,
|
|
|
|
|
organizationName: s.organization.name,
|
|
|
|
|
sentAt: s.sentAt.toISOString(),
|
|
|
|
|
})),
|
2026-07-06 20:40:19 +03:30
|
|
|
tasks: lc.tasks.map((t) => this.mapTask(t, prosthesisLabels)),
|
2026-06-28 16:46:42 +03:30
|
|
|
tasksByTooth,
|
|
|
|
|
taskProgress: {
|
|
|
|
|
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
|
|
|
|
|
total: lc.tasks.length,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-14 03:06:56 +03:30
|
|
|
private buildProsthesisGroupsFromTasks(
|
|
|
|
|
tasks: Array<{ prosthesisTypeCode: string; teeth: Prisma.JsonValue }>,
|
|
|
|
|
): Array<{ prosthesisTypeCode: string; teeth: string[] }> {
|
|
|
|
|
const prosthesisByCode = new Map<string, string[]>();
|
|
|
|
|
|
|
|
|
|
for (const task of tasks) {
|
|
|
|
|
if (!task.prosthesisTypeCode) continue;
|
|
|
|
|
const teeth = normalizeTaskTeeth(task.teeth);
|
|
|
|
|
const list = prosthesisByCode.get(task.prosthesisTypeCode) ?? [];
|
|
|
|
|
list.push(...teeth);
|
|
|
|
|
prosthesisByCode.set(task.prosthesisTypeCode, list);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...prosthesisByCode.entries()]
|
|
|
|
|
.map(([prosthesisTypeCode, teeth]) => ({
|
|
|
|
|
prosthesisTypeCode,
|
|
|
|
|
teeth: [...new Set(teeth)].sort((a, b) =>
|
|
|
|
|
a.localeCompare(b, undefined, { numeric: true }),
|
|
|
|
|
),
|
|
|
|
|
}))
|
|
|
|
|
.sort((a, b) => a.prosthesisTypeCode.localeCompare(b.prosthesisTypeCode));
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-07 15:31:09 +03:30
|
|
|
private groupTasks(
|
|
|
|
|
tasks: LabCaseTaskWithRelations[],
|
2026-07-06 20:40:19 +03:30
|
|
|
prosthesisLabels: Map<string, string>,
|
2026-06-28 16:46:42 +03:30
|
|
|
) {
|
|
|
|
|
const groups = new Map<
|
|
|
|
|
string,
|
|
|
|
|
{
|
2026-07-07 15:31:09 +03:30
|
|
|
treatmentDetailId: string;
|
|
|
|
|
teeth: string[];
|
2026-06-28 16:46:42 +03:30
|
|
|
treatmentType: string;
|
2026-07-06 20:40:19 +03:30
|
|
|
prosthesisTypeCode: string;
|
|
|
|
|
prosthesisTypeLabel: string;
|
2026-06-28 16:46:42 +03:30
|
|
|
tasks: ReturnType<CasesService['mapTask']>[];
|
|
|
|
|
}
|
|
|
|
|
>();
|
|
|
|
|
|
|
|
|
|
for (const task of tasks) {
|
2026-07-07 15:31:09 +03:30
|
|
|
const key = `${task.treatmentDetailId}:${task.prosthesisTypeCode}`;
|
2026-06-28 16:46:42 +03:30
|
|
|
const entry = groups.get(key) ?? {
|
2026-07-07 15:31:09 +03:30
|
|
|
treatmentDetailId: task.treatmentDetailId,
|
|
|
|
|
teeth: normalizeTaskTeeth(task.teeth),
|
2026-06-28 16:46:42 +03:30
|
|
|
treatmentType: task.treatmentType,
|
2026-07-06 20:40:19 +03:30
|
|
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
|
|
|
|
prosthesisTypeLabel:
|
|
|
|
|
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
2026-06-28 16:46:42 +03:30
|
|
|
tasks: [],
|
|
|
|
|
};
|
2026-07-06 20:40:19 +03:30
|
|
|
entry.tasks.push(this.mapTask(task, prosthesisLabels));
|
2026-06-28 16:46:42 +03:30
|
|
|
groups.set(key, entry);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
return [...groups.values()];
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
private mapTask(
|
2026-07-07 15:31:09 +03:30
|
|
|
task: LabCaseTaskWithRelations,
|
2026-07-06 20:40:19 +03:30
|
|
|
prosthesisLabels: Map<string, string>,
|
|
|
|
|
) {
|
2026-06-28 16:46:42 +03:30
|
|
|
return {
|
|
|
|
|
id: task.id,
|
2026-07-07 15:31:09 +03:30
|
|
|
treatmentDetailId: task.treatmentDetailId,
|
|
|
|
|
teeth: normalizeTaskTeeth(task.teeth),
|
2026-06-28 16:46:42 +03:30
|
|
|
treatmentType: task.treatmentType,
|
2026-07-06 20:40:19 +03:30
|
|
|
prosthesisTypeCode: task.prosthesisTypeCode,
|
|
|
|
|
prosthesisTypeLabel:
|
|
|
|
|
prosthesisLabels.get(task.prosthesisTypeCode) ?? task.prosthesisTypeCode,
|
|
|
|
|
workflowStepCode: task.workflowStepCode ?? '',
|
2026-06-28 16:46:42 +03:30
|
|
|
stepOrder: task.stepOrder,
|
|
|
|
|
stepLabel: task.stepLabel,
|
|
|
|
|
status: task.status,
|
2026-07-13 15:47:32 +03:30
|
|
|
assignee: task.assignee
|
|
|
|
|
? { id: task.assignee.id, name: task.assignee.name }
|
|
|
|
|
: null,
|
|
|
|
|
assignedAt: task.assignedAt?.toISOString() ?? null,
|
2026-06-28 22:56:56 +03:30
|
|
|
createdAt: task.createdAt.toISOString(),
|
2026-07-07 15:31:09 +03:30
|
|
|
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
|
|
|
|
|
lastStatusChangedBy: task.lastStatusChangedBy
|
|
|
|
|
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }
|
2026-06-28 16:46:42 +03:30
|
|
|
: null,
|
2026-07-07 15:31:09 +03:30
|
|
|
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,
|
|
|
|
|
})),
|
2026-06-28 16:46:42 +03:30
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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 } } },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|