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;
}
}
+102
View File
@@ -0,0 +1,102 @@
import { INestApplication } from '@nestjs/common';
import * as bcrypt from 'bcryptjs';
import request from 'supertest';
import { App } from 'supertest/types';
import { PrismaService } from '../src/prisma/prisma.service';
import { createTestApp } from './utils/create-test-app';
import { login } from './utils/api';
interface TaskBody {
id: string;
status: string;
completedAt: string | null;
}
describe('Tasks (e2e)', () => {
let app: INestApplication<App>;
let prisma: PrismaService;
const staffEmail = `tasks-e2e-staff-${Date.now()}@example.com`;
const readonlyEmail = `tasks-e2e-readonly-${Date.now()}@example.com`;
const password = 'SuperSecret123!';
let staffId: string;
let readonlyId: string;
let staffToken: string;
let readonlyToken: string;
let taskId: string;
beforeAll(async () => {
app = await createTestApp();
prisma = app.get(PrismaService);
const passwordHash = await bcrypt.hash(password, 12);
const staff = await prisma.user.create({
data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' },
});
staffId = staff.id;
const readonly = await prisma.user.create({
data: {
email: readonlyEmail,
passwordHash,
name: 'Readonly',
role: 'READONLY',
},
});
readonlyId = readonly.id;
staffToken = (await login(app, staffEmail, password)).accessToken;
readonlyToken = (await login(app, readonlyEmail, password)).accessToken;
});
afterAll(async () => {
if (taskId) {
await prisma.task.delete({ where: { id: taskId } });
}
await prisma.refreshToken.deleteMany({
where: { userId: { in: [staffId, readonlyId] } },
});
await prisma.auditLog.deleteMany({
where: { userId: { in: [staffId, readonlyId] } },
});
await prisma.user.deleteMany({
where: { id: { in: [staffId, readonlyId] } },
});
await app.close();
});
it('rejects create for a READONLY user', () => {
return request(app.getHttpServer())
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${readonlyToken}`)
.send({ title: 'Beute prüfen' })
.expect(403);
});
it('lets a STAFF user create a task', async () => {
const response = await request(app.getHttpServer())
.post('/api/v1/tasks')
.set('Authorization', `Bearer ${staffToken}`)
.send({ title: 'Beute prüfen', priority: 'HIGH' })
.expect(201);
const task = response.body as TaskBody;
taskId = task.id;
expect(task.status).toBe('OPEN');
});
it('lists tasks filtered by status', async () => {
const response = await request(app.getHttpServer())
.get('/api/v1/tasks?status=OPEN')
.set('Authorization', `Bearer ${readonlyToken}`)
.expect(200);
const tasks = response.body as TaskBody[];
expect(tasks.some((entry) => entry.id === taskId)).toBe(true);
});
it('completes a task', async () => {
const response = await request(app.getHttpServer())
.post(`/api/v1/tasks/${taskId}/complete`)
.set('Authorization', `Bearer ${staffToken}`)
.expect(201);
const task = response.body as TaskBody;
expect(task.status).toBe('DONE');
expect(task.completedAt).not.toBeNull();
});
});
+1 -1
View File
@@ -58,7 +58,7 @@ Abgeleitet aus [`docs/lastenheft.md`](docs/lastenheft.md) Abschnitt 26. Reihenfo
- Backend: `TasksModule` (polymorphe Verknüpfung: Colony/Apiary/Queen/HoneyBatch/Storage/General)
- Endpunkte: `GET/POST /api/v1/tasks`, `GET/PATCH /api/v1/tasks/:id`, `POST /api/v1/tasks/:id/complete`
- Frontend: `/tasks` (Liste, Filter nach Status/Priorität/Fälligkeit)
- [ ] Backend-Modul fertig, getestet, grün, committet
- [x] Backend-Modul fertig, getestet, grün, committet
- [ ] Frontend-Views fertig, getestet, grün, committet
### 7. Varroamessungen