improvement: task assignment flow added. cases and tasks feature updated accordingly.

This commit is contained in:
2026-07-13 15:47:32 +03:30
parent 0d073f1ec0
commit a391eee15f
21 changed files with 357 additions and 35 deletions

View File

@@ -14,7 +14,7 @@ import { ApiBearerAuth, 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 } from './dto/cases.dto';
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
@ApiTags('cases')
@ApiBearerAuth('JWT-auth')
@@ -30,6 +30,13 @@ export class CasesController {
return this.casesService.list(organizationId, req.user.id, query);
}
@Get('assignable-staff')
@ApiOperation({ summary: 'Staff who can be assigned lab tasks' })
listAssignableStaff(@Req() req) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.listAssignableStaff(organizationId, req.user.id);
}
@Get('filter-options')
@ApiOperation({ summary: 'Clinics and treatment types for inbox filters' })
listFilterOptions(@Req() req) {
@@ -74,4 +81,23 @@ export class CasesController {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.setCaseImportant(id, dto, organizationId, req.user.id, req.user.language);
}
@Patch(':caseId/tasks/:taskId/assign')
@ApiOperation({ summary: 'Assign or unassign a task to lab staff' })
assignTask(
@Param('caseId') caseId: string,
@Param('taskId') taskId: string,
@Body() dto: AssignLabCaseTaskDto,
@Req() req,
) {
const organizationId = this.casesService.getOrganizationIdFromUser(req.user);
return this.casesService.assignTask(
caseId,
taskId,
dto,
organizationId,
req.user.id,
req.user.language,
);
}
}

View File

@@ -14,8 +14,9 @@ import {
} from '../catalog/catalog-label.service';
import { TreatmentCatalogService } from '../treatment-catalog/treatment-catalog.service';
import { normalizeTeeth } from '../treatments/treatment.utils';
import { ListLabCasesDto, UpdateLabCaseImportantDto } from './dto/cases.dto';
import { ListLabCasesDto, UpdateLabCaseImportantDto, AssignLabCaseTaskDto } from './dto/cases.dto';
import { normalizeTaskTeeth } from './lab-case-task.util';
import { hasEffectivePermission } from '../../common/membership-permissions';
const labCaseListInclude = {
treatment: {
@@ -49,6 +50,7 @@ const labCaseListInclude = {
],
include: {
lastStatusChangedBy: { select: { id: true, name: true } },
assignee: { select: { id: true, name: true } },
statusEvents: {
orderBy: { changedAt: 'asc' as const },
include: { changedBy: { select: { id: true, name: true } } },
@@ -74,6 +76,7 @@ const labCaseListInclude = {
type LabCaseTaskWithRelations = Prisma.LabCaseTaskGetPayload<{
include: {
lastStatusChangedBy: { select: { id: true; name: true } };
assignee: { select: { id: true; name: true } };
statusEvents: {
include: { changedBy: { select: { id: true; name: true } } };
};
@@ -370,6 +373,95 @@ export class CasesService {
return { success: true, data: await this.mapLabCaseDetail(labCase, localeInput) };
}
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) };
}
private buildListWhere(
labOrganizationId: string,
query: ListLabCasesDto,
@@ -579,6 +671,10 @@ export class CasesService {
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name }
: null,
assignedAt: task.assignedAt?.toISOString() ?? null,
createdAt: task.createdAt.toISOString(),
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy

View File

@@ -6,6 +6,12 @@ export class UpdateLabCaseImportantDto {
isImportant: boolean;
}
export class AssignLabCaseTaskDto {
@IsOptional()
@IsUUID()
assigneeUserId?: string | null;
}
export class ListLabCasesDto {
@IsOptional()
@IsString()

View File

@@ -81,6 +81,12 @@ export class ListLabTasksDto {
@IsUUID()
labCaseId?: string;
/** When true, only tasks assigned to the current user. */
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
assignedToMe?: boolean;
@IsOptional()
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
sortBy?: TaskSortField;
@@ -147,6 +153,12 @@ export class LocateTaskPageDto {
@IsString()
stepCompleted?: string;
/** When true, only tasks assigned to the current user. */
@IsOptional()
@Transform(toBoolean)
@IsBoolean()
assignedToMe?: boolean;
@IsOptional()
@IsIn(['date', 'status', 'clinic', 'patient', 'important', 'prosthesis', 'taskType'])
sortBy?: TaskSortField;

View File

@@ -17,6 +17,7 @@ import { hasEffectivePermission } from '../../common/membership-permissions';
const taskListInclude = {
lastStatusChangedBy: { select: { id: true, name: true } },
assignee: { select: { id: true, name: true } },
labCase: {
include: {
treatment: {
@@ -55,7 +56,7 @@ export class TasksService {
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const skip = (page - 1) * limit;
const where = await this.buildListWhere(labOrganizationId, query);
const where = await this.buildListWhere(labOrganizationId, actorUserId, query);
const [items, total] = await Promise.all([
this.prisma.labCaseTask.findMany({
@@ -117,7 +118,7 @@ export class TasksService {
throw new NotFoundException('Task not found');
}
const where = await this.buildListWhere(labOrganizationId, listQuery);
const where = await this.buildListWhere(labOrganizationId, actorUserId, listQuery);
const inFilteredSet = await this.prisma.labCaseTask.count({
where: { AND: [where, { id: query.taskId }] },
});
@@ -176,6 +177,10 @@ export class TasksService {
throw new NotFoundException('Task not found');
}
if (task.assigneeUserId && task.assigneeUserId !== actorUserId) {
throw new ForbiddenException('This task is assigned to another staff member');
}
const updated = await this.prisma.$transaction(async (tx) => {
const result = await tx.labCaseTask.update({
where: { id: taskId },
@@ -263,6 +268,7 @@ export class TasksService {
private async buildListWhere(
labOrganizationId: string,
actorUserId: string,
query: ListLabTasksDto,
): Promise<Prisma.LabCaseTaskWhereInput> {
const sentAtFilter: Prisma.DateTimeNullableFilter = { not: null };
@@ -306,6 +312,7 @@ export class TasksService {
...(query.labCaseId ? { labCaseId: query.labCaseId } : {}),
labCase: labCaseScope,
...(status !== undefined ? { status } : {}),
...(query.assignedToMe ? { assigneeUserId: actorUserId } : {}),
};
const stepCompleted = query.stepCompleted?.trim();
@@ -327,12 +334,16 @@ export class TasksService {
}
return {
...base,
OR: completedGroups.map((group) => ({
labCaseId: group.labCaseId,
treatmentDetailId: group.treatmentDetailId,
prosthesisTypeCode: group.prosthesisTypeCode,
})),
AND: [
base,
{
OR: completedGroups.map((group) => ({
labCaseId: group.labCaseId,
treatmentDetailId: group.treatmentDetailId,
prosthesisTypeCode: group.prosthesisTypeCode,
})),
},
],
};
}
@@ -348,6 +359,7 @@ export class TasksService {
sentFrom: query.sentFrom,
sentTo: query.sentTo,
stepCompleted: query.stepCompleted,
assignedToMe: query.assignedToMe,
sortBy: query.sortBy,
sortDir: query.sortDir,
limit: query.limit,
@@ -527,6 +539,10 @@ export class TasksService {
stepLabel: task.stepLabel,
status: task.status,
isImportant: task.labCase.isImportant,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name }
: null,
assignedAt: task.assignedAt?.toISOString() ?? null,
lastStatusChangedAt: task.lastStatusChangedAt?.toISOString() ?? null,
lastStatusChangedBy: task.lastStatusChangedBy
? { id: task.lastStatusChangedBy.id, name: task.lastStatusChangedBy.name }