feature: Phase3 - Task templates + generation on send
This commit is contained in:
405
backend/src/modules/cases/cases.service.ts
Normal file
405
backend/src/modules/cases/cases.service.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
import {
|
||||
BadRequestException,
|
||||
ForbiddenException,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { LabTaskStatus, Prisma } from '@prisma/client';
|
||||
import { PrismaService } from '../../../prisma/prisma.service';
|
||||
import { normalizeMobile } from '../../common/phone';
|
||||
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
|
||||
import { normalizeTeeth } from '../treatments/treatment.utils';
|
||||
import { ListLabCasesDto, UpdateLabCaseTaskDto } from './dto/cases.dto';
|
||||
|
||||
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: [
|
||||
{ tooth: 'asc' as const },
|
||||
{ treatmentType: 'asc' as const },
|
||||
{ stepOrder: 'asc' as const },
|
||||
],
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
},
|
||||
} satisfies Prisma.LabCaseInclude;
|
||||
|
||||
@Injectable()
|
||||
export class CasesService {
|
||||
constructor(
|
||||
private readonly prisma: PrismaService,
|
||||
private readonly treatmentCatalog: TreatmentCatalogService,
|
||||
) {}
|
||||
|
||||
getOrganizationIdFromUser(user: { organizationId?: string }) {
|
||||
if (!user?.organizationId) {
|
||||
throw new BadRequestException('Organization is not selected');
|
||||
}
|
||||
return user.organizationId;
|
||||
}
|
||||
|
||||
async list(labOrganizationId: string, actorUserId: string, query: ListLabCasesDto) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
if (query.treatmentType) {
|
||||
this.treatmentCatalog.assertKnownTreatmentType(query.treatmentType);
|
||||
}
|
||||
|
||||
const page = query.page ?? 1;
|
||||
const limit = Math.min(Math.max(query.limit ?? 20, 1), 100);
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const where: Prisma.LabCaseWhereInput = {
|
||||
sentAt: { not: null },
|
||||
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())
|
||||
: {}),
|
||||
};
|
||||
|
||||
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 getOne(labCaseId: string, labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
const labCase = await this.prisma.labCase.findFirst({
|
||||
where: {
|
||||
id: labCaseId,
|
||||
sentAt: { not: null },
|
||||
sends: { some: { organizationId: labOrganizationId } },
|
||||
},
|
||||
include: labCaseListInclude,
|
||||
});
|
||||
|
||||
if (!labCase) {
|
||||
throw new NotFoundException('Case not found');
|
||||
}
|
||||
|
||||
return { success: true, data: this.mapLabCaseDetail(labCase) };
|
||||
}
|
||||
|
||||
async updateTask(
|
||||
labCaseId: string,
|
||||
taskId: string,
|
||||
dto: UpdateLabCaseTaskDto,
|
||||
labOrganizationId: string,
|
||||
actorUserId: string,
|
||||
) {
|
||||
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');
|
||||
}
|
||||
|
||||
if (dto.assigneeUserId !== undefined && dto.assigneeUserId !== null) {
|
||||
await this.ensureLabMember(dto.assigneeUserId, labOrganizationId);
|
||||
}
|
||||
|
||||
const updated = await this.prisma.labCaseTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
...(dto.assigneeUserId !== undefined ? { assigneeUserId: dto.assigneeUserId } : {}),
|
||||
...(dto.status !== undefined ? { status: dto.status } : {}),
|
||||
},
|
||||
include: {
|
||||
assignee: { select: { id: true, name: true, email: true } },
|
||||
},
|
||||
});
|
||||
|
||||
return { success: true, data: this.mapTask(updated) };
|
||||
}
|
||||
|
||||
async listAssignableMembers(labOrganizationId: string, actorUserId: string) {
|
||||
await this.assertCanReadCases(actorUserId, labOrganizationId);
|
||||
|
||||
const memberships = await this.prisma.membership.findMany({
|
||||
where: { organizationId: labOrganizationId, isActive: true },
|
||||
include: { user: { select: { id: true, name: true, email: true } } },
|
||||
orderBy: [{ isOwner: 'desc' }, { createdAt: 'asc' }],
|
||||
});
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: memberships.map((m) => ({
|
||||
userId: m.user.id,
|
||||
name: m.user.name,
|
||||
email: m.user.email,
|
||||
isOwner: m.isOwner,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private buildSearchWhere(q: string): Prisma.LabCaseWhereInput {
|
||||
const orConditions: Prisma.LabCaseWhereInput[] = [
|
||||
{
|
||||
treatment: {
|
||||
patient: {
|
||||
OR: [
|
||||
{ firstName: { contains: q, mode: 'insensitive' } },
|
||||
{ lastName: { contains: q, mode: 'insensitive' } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
{
|
||||
treatment: {
|
||||
organization: { name: { contains: q, mode: 'insensitive' } },
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const normalized = normalizeMobile(q);
|
||||
if (normalized) {
|
||||
orConditions.push({
|
||||
treatment: { patient: { mobile: normalized } },
|
||||
});
|
||||
}
|
||||
|
||||
return { OR: orConditions };
|
||||
}
|
||||
|
||||
private mapLabCaseListItem(lc: {
|
||||
id: string;
|
||||
sentAt: Date | null;
|
||||
treatment: {
|
||||
organization: { id: string; name: string };
|
||||
patient: { id: string; firstName: string; lastName: string; mobile: string };
|
||||
};
|
||||
details: Array<{ detail: { treatmentType: string } }>;
|
||||
tasks: Array<{ id: string; status: LabTaskStatus }>;
|
||||
}) {
|
||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||
const completedTasks = lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length;
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: {
|
||||
id: lc.treatment.patient.id,
|
||||
firstName: lc.treatment.patient.firstName,
|
||||
lastName: lc.treatment.patient.lastName,
|
||||
mobile: lc.treatment.patient.mobile,
|
||||
},
|
||||
treatmentTypes,
|
||||
taskProgress: {
|
||||
completed: completedTasks,
|
||||
total: lc.tasks.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private mapLabCaseDetail(lc: Prisma.LabCaseGetPayload<{ include: typeof labCaseListInclude }>) {
|
||||
const treatmentTypes = [...new Set(lc.details.map((d) => d.detail.treatmentType))];
|
||||
const tasksByTooth = this.groupTasksByTooth(lc.tasks);
|
||||
|
||||
return {
|
||||
id: lc.id,
|
||||
sentAt: lc.sentAt?.toISOString() ?? null,
|
||||
labComment: lc.labComment,
|
||||
clinic: lc.treatment.organization,
|
||||
patient: lc.treatment.patient,
|
||||
appointmentStartAt: lc.treatment.appointment?.startAt.toISOString() ?? null,
|
||||
treatmentTypes,
|
||||
details: lc.details.map((link) => ({
|
||||
id: link.detail.id,
|
||||
treatmentType: link.detail.treatmentType,
|
||||
teeth: normalizeTeeth(link.detail.teeth),
|
||||
comment: link.detail.comment,
|
||||
})),
|
||||
sends: lc.sends.map((s) => ({
|
||||
organizationId: s.organizationId,
|
||||
organizationName: s.organization.name,
|
||||
sentAt: s.sentAt.toISOString(),
|
||||
})),
|
||||
tasks: lc.tasks.map((t) => this.mapTask(t)),
|
||||
tasksByTooth,
|
||||
taskProgress: {
|
||||
completed: lc.tasks.filter((t) => t.status === LabTaskStatus.COMPLETED).length,
|
||||
total: lc.tasks.length,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private groupTasksByTooth(
|
||||
tasks: Array<{
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
assigneeUserId: string | null;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
}>,
|
||||
) {
|
||||
const groups = new Map<
|
||||
string,
|
||||
{
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
tasks: ReturnType<CasesService['mapTask']>[];
|
||||
}
|
||||
>();
|
||||
|
||||
for (const task of tasks) {
|
||||
const key = `${task.tooth}:${task.treatmentType}`;
|
||||
const entry = groups.get(key) ?? {
|
||||
tooth: task.tooth,
|
||||
treatmentType: task.treatmentType,
|
||||
tasks: [],
|
||||
};
|
||||
entry.tasks.push(this.mapTask(task));
|
||||
groups.set(key, entry);
|
||||
}
|
||||
|
||||
return [...groups.values()];
|
||||
}
|
||||
|
||||
private mapTask(task: {
|
||||
id: string;
|
||||
tooth: string;
|
||||
treatmentType: string;
|
||||
stepOrder: number;
|
||||
stepLabel: string;
|
||||
status: LabTaskStatus;
|
||||
assigneeUserId: string | null;
|
||||
assignee: { id: string; name: string; email: string } | null;
|
||||
}) {
|
||||
return {
|
||||
id: task.id,
|
||||
tooth: task.tooth,
|
||||
treatmentType: task.treatmentType,
|
||||
stepOrder: task.stepOrder,
|
||||
stepLabel: task.stepLabel,
|
||||
status: task.status,
|
||||
assigneeUserId: task.assigneeUserId,
|
||||
assignee: task.assignee
|
||||
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
|
||||
: null,
|
||||
};
|
||||
}
|
||||
|
||||
private async ensureLabMember(userId: string, labOrganizationId: string) {
|
||||
const membership = await this.prisma.membership.findFirst({
|
||||
where: { userId, organizationId: labOrganizationId, isActive: true },
|
||||
select: { id: true },
|
||||
});
|
||||
if (!membership) {
|
||||
throw new BadRequestException('Assignee must be an active member of this lab');
|
||||
}
|
||||
}
|
||||
|
||||
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 } } },
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user