feat(backend): Aufgaben-Modul (Modul 6)

TasksModule mit CRUD, Status-/Prioritätsfilter und
Erledigen-Endpunkt für polymorph verknüpfte Aufgaben.
This commit is contained in:
Nick Szaunig committed 2026-08-21 23:43:47 +02:00
1 parent 829753ec90
commit 7bba168bd6
9 files changed
+408 -1

No files matched your search

+2
View File
@@ -10,6 +10,7 @@ import { ApiariesModule } from './apiaries/apiaries.module';
import { ColoniesModule } from './colonies/colonies.module';
import { QueensModule } from './queens/queens.module';
import { InspectionsModule } from './inspections/inspections.module';
import { TasksModule } from './tasks/tasks.module';
import { JwtAuthGuard } from './auth/guards/jwt-auth.guard';
import { RolesGuard } from './auth/guards/roles.guard';
@@ -25,6 +26,7 @@ import { RolesGuard } from './auth/guards/roles.guard';
ColoniesModule,
QueensModule,
InspectionsModule,
TasksModule,
],
controllers: [],
providers: [
@@ -0,0 +1,39 @@
import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
import { TaskPriority, TaskRelatedType } from '../../../generated/prisma';
export class CreateTaskDto {
@ApiProperty()
@IsString()
title!: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ enum: TaskPriority })
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
dueDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedUserId?: string;
@ApiPropertyOptional({ enum: TaskRelatedType })
@IsOptional()
@IsEnum(TaskRelatedType)
relatedType?: TaskRelatedType;
@ApiPropertyOptional()
@IsOptional()
@IsString()
relatedId?: string;
}
@@ -0,0 +1,35 @@
import { ApiPropertyOptional } from '@nestjs/swagger';
import { IsDateString, IsEnum, IsOptional, IsString } from 'class-validator';
import { TaskPriority, TaskStatus } from '../../../generated/prisma';
export class UpdateTaskDto {
@ApiPropertyOptional()
@IsOptional()
@IsString()
title?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
description?: string;
@ApiPropertyOptional({ enum: TaskPriority })
@IsOptional()
@IsEnum(TaskPriority)
priority?: TaskPriority;
@ApiPropertyOptional()
@IsOptional()
@IsDateString()
dueDate?: string;
@ApiPropertyOptional()
@IsOptional()
@IsString()
assignedUserId?: string;
@ApiPropertyOptional({ enum: TaskStatus })
@IsOptional()
@IsEnum(TaskStatus)
status?: TaskStatus;
}
@@ -0,0 +1,59 @@
import {
Body,
Controller,
Get,
Param,
Patch,
Post,
Query,
} from '@nestjs/common';
import { ApiBearerAuth, ApiTags } from '@nestjs/swagger';
import { Role, TaskPriority, TaskStatus } from '../../generated/prisma';
import { Roles } from '../auth/decorators/roles.decorator';
import { CurrentUser } from '../auth/decorators/current-user.decorator';
import type { AuthenticatedUser } from '../auth/types/authenticated-user';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
import { TasksService } from './tasks.service';
@ApiTags('tasks')
@ApiBearerAuth()
@Controller('tasks')
export class TasksController {
constructor(private readonly tasksService: TasksService) {}
@Get()
findAll(
@Query('status') status?: TaskStatus,
@Query('priority') priority?: TaskPriority,
) {
return this.tasksService.findAll({ status, priority });
}
@Get(':id')
findOne(@Param('id') id: string) {
return this.tasksService.findById(id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post()
create(@Body() dto: CreateTaskDto, @CurrentUser() actor: AuthenticatedUser) {
return this.tasksService.create(dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Patch(':id')
update(
@Param('id') id: string,
@Body() dto: UpdateTaskDto,
@CurrentUser() actor: AuthenticatedUser,
) {
return this.tasksService.update(id, dto, actor.id);
}
@Roles(Role.ADMIN, Role.BEEKEEPER, Role.STAFF)
@Post(':id/complete')
complete(@Param('id') id: string, @CurrentUser() actor: AuthenticatedUser) {
return this.tasksService.complete(id, actor.id);
}
}
+10
View File
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { TasksController } from './tasks.controller';
import { TasksService } from './tasks.service';
@Module({
controllers: [TasksController],
providers: [TasksService],
exports: [TasksService],
})
export class TasksModule {}
@@ -0,0 +1,67 @@
import { NotFoundException } from '@nestjs/common';
import { TasksService } from './tasks.service';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
describe('TasksService', () => {
let service: TasksService;
let prisma: {
task: {
findMany: jest.Mock;
findUnique: jest.Mock;
create: jest.Mock;
update: jest.Mock;
};
};
let auditLog: { record: jest.Mock };
const task = { id: 'task-1', title: 'Beute prüfen', status: 'OPEN' };
beforeEach(() => {
prisma = {
task: {
findMany: jest.fn(),
findUnique: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
};
auditLog = { record: jest.fn() };
service = new TasksService(
prisma as unknown as PrismaService,
auditLog as unknown as AuditLogService,
);
});
it('findById throws NotFoundException when missing', async () => {
prisma.task.findUnique.mockResolvedValue(null);
await expect(service.findById('missing')).rejects.toBeInstanceOf(
NotFoundException,
);
});
it('create stores the acting user as creator', async () => {
prisma.task.create.mockResolvedValue(task);
await service.create({ title: 'Beute prüfen' }, 'user-1');
const [[createArg]] = prisma.task.create.mock.calls as [
[{ data: { title: string; createdByUserId: string } }],
];
expect(createArg.data.title).toBe('Beute prüfen');
expect(createArg.data.createdByUserId).toBe('user-1');
});
it('complete sets status DONE and completedAt', async () => {
prisma.task.findUnique.mockResolvedValue(task);
prisma.task.update.mockResolvedValue({ ...task, status: 'DONE' });
await service.complete('task-1', 'user-1');
const [[updateArg]] = prisma.task.update.mock.calls as [
[{ data: { status: string; completedAt: Date } }],
];
expect(updateArg.data.status).toBe('DONE');
expect(updateArg.data.completedAt).toBeInstanceOf(Date);
});
});
@@ -0,0 +1,93 @@
import { Injectable, NotFoundException } from '@nestjs/common';
import { PrismaService } from '../prisma/prisma.service';
import { AuditLogService } from '../audit/audit-log.service';
import { Task, TaskPriority, TaskStatus } from '../../generated/prisma';
import { CreateTaskDto } from './dto/create-task.dto';
import { UpdateTaskDto } from './dto/update-task.dto';
export interface TaskFilter {
status?: TaskStatus;
priority?: TaskPriority;
}
@Injectable()
export class TasksService {
constructor(
private readonly prisma: PrismaService,
private readonly auditLog: AuditLogService,
) {}
async findAll(filter: TaskFilter): Promise<Task[]> {
return this.prisma.task.findMany({
where: filter,
orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }],
});
}
async findById(id: string): Promise<Task> {
const task = await this.prisma.task.findUnique({ where: { id } });
if (!task) {
throw new NotFoundException('Task not found');
}
return task;
}
async create(dto: CreateTaskDto, actingUserId: string): Promise<Task> {
const task = await this.prisma.task.create({
data: {
...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
createdByUserId: actingUserId,
},
});
await this.auditLog.record({
entityType: 'Task',
entityId: task.id,
action: 'CREATE',
userId: actingUserId,
afterData: task,
});
return task;
}
async update(
id: string,
dto: UpdateTaskDto,
actingUserId: string,
): Promise<Task> {
const before = await this.findById(id);
const task = await this.prisma.task.update({
where: { id },
data: {
...dto,
dueDate: dto.dueDate ? new Date(dto.dueDate) : undefined,
},
});
await this.auditLog.record({
entityType: 'Task',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: task,
});
return task;
}
async complete(id: string, actingUserId: string): Promise<Task> {
const before = await this.findById(id);
const task = await this.prisma.task.update({
where: { id },
data: { status: 'DONE', completedAt: new Date() },
});
await this.auditLog.record({
entityType: 'Task',
entityId: id,
action: 'UPDATE',
userId: actingUserId,
beforeData: before,
afterData: task,
});
return task;
}
}