feature: Tasks tab added for lab organizations. tasks now can be assigned and their status can be updated by the assignee.

This commit is contained in:
2026-06-28 22:56:56 +03:30
parent feb0b26ad0
commit 2a48946a51
29 changed files with 851 additions and 43 deletions

View File

@@ -0,0 +1,23 @@
import { IsEnum, IsInt, IsOptional, Max, Min } from 'class-validator';
import { Transform } from 'class-transformer';
import { LabTaskStatus } from '@prisma/client';
export class UpdateLabTaskDto {
@IsEnum(LabTaskStatus)
status: LabTaskStatus;
}
export class ListLabTasksDto {
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
page = 1;
@IsOptional()
@Transform(({ value }) => Number(value))
@IsInt()
@Min(1)
@Max(100)
limit = 50;
}

View File

@@ -0,0 +1,32 @@
import { Body, Controller, Get, Param, Patch, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { LabOrgGuard } from '../../common/guards/lab-org.guard';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
import { TasksService } from './tasks.service';
@ApiTags('tasks')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard, LabOrgGuard)
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
@ApiOperation({ summary: 'List lab tasks (owner: all, staff: assigned only)' })
list(@Query() query: ListLabTasksDto, @Req() req) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.list(organizationId, req.user.id, query);
}
@Patch(':taskId')
@ApiOperation({ summary: 'Update task status' })
updateStatus(
@Param('taskId') taskId: string,
@Body() dto: UpdateLabTaskDto,
@Req() req,
) {
const organizationId = this.tasksService.getOrganizationIdFromUser(req.user);
return this.tasksService.updateStatus(taskId, dto, organizationId, req.user.id);
}
}

View File

@@ -0,0 +1,9 @@
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
})
export class TasksModule {}

View File

@@ -0,0 +1,188 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LabTaskStatus, Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service';
import { ListLabTasksDto, UpdateLabTaskDto } from './dto/tasks.dto';
const taskListInclude = {
assignee: { select: { id: true, name: true, email: true } },
labCase: {
include: {
treatment: {
include: {
organization: { select: { id: true, name: true } },
patient: { select: { id: true, firstName: true, lastName: true } },
},
},
},
},
} satisfies Prisma.LabCaseTaskInclude;
@Injectable()
export class TasksService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async list(labOrganizationId: string, actorUserId: string, query: ListLabTasksDto) {
await this.assertCanReadTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId);
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
const page = query.page ?? 1;
const limit = Math.min(Math.max(query.limit ?? 50, 1), 100);
const skip = (page - 1) * limit;
const where: Prisma.LabCaseTaskWhereInput = {
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
...(membership.isOwner ? {} : { assigneeUserId: actorUserId }),
};
const [items, total] = await Promise.all([
this.prisma.labCaseTask.findMany({
where,
include: taskListInclude,
orderBy: [
{ assignedAt: { sort: 'desc', nulls: 'first' } },
{ createdAt: 'desc' },
{ labCaseId: 'asc' },
{ priority: 'desc' },
{ stepOrder: 'asc' },
{ id: 'asc' },
],
skip,
take: limit,
}),
this.prisma.labCaseTask.count({ where }),
]);
return {
success: true,
data: {
items: items.map((task) => this.mapTaskListItem(task)),
pagination: {
page,
limit,
total,
totalPages: Math.max(1, Math.ceil(total / limit)),
},
},
};
}
async updateStatus(
taskId: string,
dto: UpdateLabTaskDto,
labOrganizationId: string,
actorUserId: string,
) {
await this.assertCanEditTasks(actorUserId, labOrganizationId);
const membership = await this.getMembership(actorUserId, labOrganizationId);
if (!membership) {
throw new ForbiddenException('You are not a member of this organization');
}
const task = await this.prisma.labCaseTask.findFirst({
where: {
id: taskId,
labCase: {
sentAt: { not: null },
sends: { some: { organizationId: labOrganizationId } },
},
},
include: taskListInclude,
});
if (!task) {
throw new NotFoundException('Task not found');
}
if (!membership.isOwner && task.assigneeUserId !== actorUserId) {
throw new ForbiddenException('You can only update tasks assigned to you');
}
const updated = await this.prisma.labCaseTask.update({
where: { id: taskId },
data: { status: dto.status },
include: taskListInclude,
});
return { success: true, data: this.mapTaskListItem(updated) };
}
private mapTaskListItem(
task: Prisma.LabCaseTaskGetPayload<{ include: typeof taskListInclude }>,
) {
return {
id: task.id,
labCaseId: task.labCaseId,
tooth: task.tooth,
treatmentType: task.treatmentType,
stepOrder: task.stepOrder,
stepLabel: task.stepLabel,
status: task.status,
priority: task.priority,
assignedAt: task.assignedAt?.toISOString() ?? null,
createdAt: task.createdAt.toISOString(),
assigneeUserId: task.assigneeUserId,
assignee: task.assignee
? { id: task.assignee.id, name: task.assignee.name, email: task.assignee.email }
: 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,
},
};
}
private async assertCanReadTasks(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_TASKS_READ') || names.includes('TAB_TASKS_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to tasks');
}
private async assertCanEditTasks(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_TASKS_EDIT')) {
return;
}
throw new ForbiddenException('You cannot update tasks');
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId, isActive: true },
include: { permissions: { include: { permission: true } } },
});
}
}