2026-06-28 16:46:42 +03:30
|
|
|
import {
|
|
|
|
|
BadRequestException,
|
|
|
|
|
ForbiddenException,
|
|
|
|
|
Injectable,
|
|
|
|
|
NotFoundException,
|
|
|
|
|
} from '@nestjs/common';
|
2026-07-06 20:40:19 +03:30
|
|
|
import { CatalogEntityKind, 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-06-28 16:46:42 +03:30
|
|
|
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
|
|
|
|
import { normalizeTeeth } from '../treatments/treatment.utils';
|
|
|
|
|
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
2026-07-07 15:31:09 +03:30
|
|
|
import { normalizeTaskTeeth } from './lab-case-task.util';
|
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 } },
|
|
|
|
|
statusEvents: {
|
|
|
|
|
orderBy: { changedAt: 'asc' as const },
|
|
|
|
|
include: { changedBy: { select: { id: true, name: 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 } };
|
|
|
|
|
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,
|
|
|
|
|
private readonly treatmentCatalog: TreatmentCatalogService,
|
2026-07-06 20:40:19 +03:30
|
|
|
private readonly catalogLabels: CatalogLabelService,
|
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);
|
|
|
|
|
|
|
|
|
|
if (query.treatmentType) {
|
|
|
|
|
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const page = query.page ?? 1;
|
|
|
|
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
|
|
|
|
const skip = (page - 1) * limit;
|
|
|
|
|
|
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 } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
details: {
|
|
|
|
|
include: {
|
|
|
|
|
detail: { select: { treatmentType: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
tasks: { select: { id: true, status: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ sentAt: 'desc' }],
|
|
|
|
|
skip,
|
|
|
|
|
take: limit,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.labCase.count({ where }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
items: items.map((lc) => this.mapLabCaseListItem(lc)),
|
|
|
|
|
pagination: {
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
total,
|
|
|
|
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
2026-06-28 17:49:40 +03:30
|
|
|
async listFilterOptions(labOrganizationId: string, actorUserId: string) {
|
|
|
|
|
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
|
|
|
|
|
|
|
|
|
const rows = await this.prisma.labCase.findMany({
|
|
|
|
|
where: {
|
|
|
|
|
sentAt: { not: null },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
|
|
|
|
},
|
|
|
|
|
select: {
|
|
|
|
|
treatment: {
|
|
|
|
|
select: {
|
|
|
|
|
organization: { select: { id: true, name: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
details: {
|
|
|
|
|
select: { detail: { select: { treatmentType: true } } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
const clinicsById = new Map<string, { id: string; name: string }>();
|
|
|
|
|
const typeCodes = new Set<string>();
|
|
|
|
|
|
|
|
|
|
for (const row of rows) {
|
|
|
|
|
clinicsById.set(row.treatment.organization.id, row.treatment.organization);
|
|
|
|
|
for (const link of row.details) {
|
|
|
|
|
typeCodes.add(link.detail.treatmentType);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const catalog = await this.treatmentCatalog.list();
|
|
|
|
|
const treatmentTypes = catalog
|
2026-06-28 17:49:40 +03:30
|
|
|
.filter((entry) => entry.labDependent && typeCodes.has(entry.code))
|
|
|
|
|
.map((entry) => ({ code: entry.code, labDependent: entry.labDependent }));
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
clinics: [...clinicsById.values()].sort((a, b) => a.name.localeCompare(b.name)),
|
|
|
|
|
treatmentTypes,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
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,
|
|
|
|
|
) {
|
|
|
|
|
if (query.treatmentType) {
|
|
|
|
|
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const page = query.page ?? 1;
|
|
|
|
|
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
|
|
|
|
const skip = (page - 1) * limit;
|
|
|
|
|
|
|
|
|
|
const where: Prisma.LabCaseWhereInput = {
|
|
|
|
|
...this.buildListWhere(labOrganizationId, query),
|
|
|
|
|
treatment: { organizationId: clinicOrganizationId },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const [items, total] = await Promise.all([
|
|
|
|
|
this.prisma.labCase.findMany({
|
|
|
|
|
where,
|
|
|
|
|
include: {
|
|
|
|
|
treatment: {
|
|
|
|
|
include: {
|
|
|
|
|
organization: { select: { id: true, name: true } },
|
|
|
|
|
patient: { select: { id: true, firstName: true, lastName: true, mobile: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
details: {
|
|
|
|
|
include: {
|
|
|
|
|
detail: { select: { treatmentType: true } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
tasks: { select: { id: true, status: true } },
|
|
|
|
|
},
|
|
|
|
|
orderBy: [{ sentAt: 'desc' }],
|
|
|
|
|
skip,
|
|
|
|
|
take: limit,
|
|
|
|
|
}),
|
|
|
|
|
this.prisma.labCase.count({ where }),
|
|
|
|
|
]);
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
success: true,
|
|
|
|
|
data: {
|
|
|
|
|
items: items.map((lc) => this.mapLabCaseListItem(lc)),
|
|
|
|
|
pagination: {
|
|
|
|
|
page,
|
|
|
|
|
limit,
|
|
|
|
|
total,
|
|
|
|
|
totalPages: Math.max(1, Math.ceil(total / limit)),
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async getOneBetweenOrganizations(
|
|
|
|
|
labCaseId: string,
|
|
|
|
|
clinicOrganizationId: string,
|
|
|
|
|
labOrganizationId: string,
|
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
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async updateTask(
|
|
|
|
|
labCaseId: string,
|
|
|
|
|
taskId: string,
|
|
|
|
|
dto: UpdateLabCaseTaskDto,
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
const task = await this.prisma.labCaseTask.findFirst({
|
|
|
|
|
where: {
|
|
|
|
|
id: taskId,
|
|
|
|
|
labCaseId,
|
|
|
|
|
labCase: {
|
|
|
|
|
sentAt: { not: null },
|
|
|
|
|
sends: { some: { organizationId: labOrganizationId } },
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
if (!task) {
|
|
|
|
|
throw new NotFoundException('Task not found');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
const updated = await this.prisma.labCaseTask.update({
|
|
|
|
|
where: { id: taskId },
|
2026-07-07 15:31:09 +03:30
|
|
|
data: { isImportant: dto.isImportant },
|
2026-06-28 16:46:42 +03:30
|
|
|
include: {
|
2026-07-07 15:31:09 +03:30
|
|
|
lastStatusChangedBy: { select: { id: true, name: true } },
|
|
|
|
|
statusEvents: {
|
|
|
|
|
orderBy: { changedAt: 'asc' },
|
|
|
|
|
include: { changedBy: { select: { id: true, name: true } } },
|
|
|
|
|
},
|
2026-06-28 16:46:42 +03:30
|
|
|
},
|
|
|
|
|
});
|
|
|
|
|
|
2026-07-06 20:40:19 +03:30
|
|
|
const locale = normalizeCatalogLocale(localeInput);
|
|
|
|
|
const prosthesisLabels = await this.catalogLabels.resolveLabels(
|
|
|
|
|
CatalogEntityKind.PROSTHESIS_TYPE,
|
|
|
|
|
[updated.prosthesisTypeCode],
|
|
|
|
|
locale,
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
return { success: true, data: this.mapTask(updated, prosthesisLabels) };
|
2026-06-28 16:46:42 +03:30
|
|
|
}
|
|
|
|
|
|
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 } }
|
|
|
|
|
: {}),
|
|
|
|
|
...(query.treatmentType
|
|
|
|
|
? {
|
|
|
|
|
details: {
|
|
|
|
|
some: { detail: { treatmentType: query.treatmentType } },
|
|
|
|
|
},
|
|
|
|
|
}
|
|
|
|
|
: {}),
|
|
|
|
|
...(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;
|
|
|
|
|
treatment: {
|
|
|
|
|
organization: { id: string; name: string };
|
|
|
|
|
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
|
|
|
|
};
|
|
|
|
|
details: Array<{ detail: { treatmentType: string } }>;
|
|
|
|
|
tasks: Array<{ id: string; status: LabTaskStatus }>;
|
|
|
|
|
}) {
|
|
|
|
|
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
|
|
|
|
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: lc.id,
|
|
|
|
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
|
|
|
|
clinic: lc.treatment.organization,
|
|
|
|
|
patient: {
|
|
|
|
|
id: lc.treatment.patient.id,
|
|
|
|
|
firstName: lc.treatment.patient.firstName,
|
|
|
|
|
lastName: lc.treatment.patient.lastName,
|
|
|
|
|
mobile: lc.treatment.patient.mobile,
|
|
|
|
|
},
|
|
|
|
|
treatmentTypes,
|
|
|
|
|
taskProgress: {
|
|
|
|
|
completed: completedTasks,
|
|
|
|
|
total: lc.tasks.length,
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
|
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-06-28 16:46:42 +03:30
|
|
|
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
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-06-28 16:46:42 +03:30
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
id: lc.id,
|
|
|
|
|
sentAt: lc.sentAt?.toISOString() ?? null,
|
|
|
|
|
clinic: lc.treatment.organization,
|
|
|
|
|
patient: lc.treatment.patient,
|
|
|
|
|
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
|
|
|
|
treatmentTypes,
|
|
|
|
|
details: lc.details.map((link) => ({
|
|
|
|
|
id: link.detail.id,
|
|
|
|
|
treatmentType: link.detail.treatmentType,
|
|
|
|
|
teeth: normalizeTeeth(link.detail.teeth),
|
|
|
|
|
comment: link.detail.comment,
|
|
|
|
|
})),
|
|
|
|
|
sends: lc.sends.map((s) => ({
|
|
|
|
|
organizationId: s.organizationId,
|
|
|
|
|
organizationName: s.organization.name,
|
|
|
|
|
sentAt: s.sentAt.toISOString(),
|
|
|
|
|
})),
|
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-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-07 15:31:09 +03:30
|
|
|
isImportant: task.isImportant,
|
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 } } },
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|