From 7bba168bd6a98d34a63ffb8872b87bf7ae69fa8b Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Fri, 21 Aug 2026 23:43:47 +0200 Subject: [PATCH] feat(backend): Aufgaben-Modul (Modul 6) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit TasksModule mit CRUD, Status-/Prioritätsfilter und Erledigen-Endpunkt für polymorph verknüpfte Aufgaben. --- ApiCore-Backend/src/app.module.ts | 2 + .../src/tasks/dto/create-task.dto.ts | 39 +++++++ .../src/tasks/dto/update-task.dto.ts | 35 ++++++ ApiCore-Backend/src/tasks/tasks.controller.ts | 59 ++++++++++ ApiCore-Backend/src/tasks/tasks.module.ts | 10 ++ .../src/tasks/tasks.service.spec.ts | 67 ++++++++++++ ApiCore-Backend/src/tasks/tasks.service.ts | 93 ++++++++++++++++ ApiCore-Backend/test/tasks.e2e-spec.ts | 102 ++++++++++++++++++ TASKS.md | 2 +- 9 files changed, 408 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/src/tasks/dto/create-task.dto.ts create mode 100644 ApiCore-Backend/src/tasks/dto/update-task.dto.ts create mode 100644 ApiCore-Backend/src/tasks/tasks.controller.ts create mode 100644 ApiCore-Backend/src/tasks/tasks.module.ts create mode 100644 ApiCore-Backend/src/tasks/tasks.service.spec.ts create mode 100644 ApiCore-Backend/src/tasks/tasks.service.ts create mode 100644 ApiCore-Backend/test/tasks.e2e-spec.ts diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 98477ec..4c2c17d 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -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: [ diff --git a/ApiCore-Backend/src/tasks/dto/create-task.dto.ts b/ApiCore-Backend/src/tasks/dto/create-task.dto.ts new file mode 100644 index 0000000..f812ea2 --- /dev/null +++ b/ApiCore-Backend/src/tasks/dto/create-task.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/tasks/dto/update-task.dto.ts b/ApiCore-Backend/src/tasks/dto/update-task.dto.ts new file mode 100644 index 0000000..082869e --- /dev/null +++ b/ApiCore-Backend/src/tasks/dto/update-task.dto.ts @@ -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; +} diff --git a/ApiCore-Backend/src/tasks/tasks.controller.ts b/ApiCore-Backend/src/tasks/tasks.controller.ts new file mode 100644 index 0000000..92676ea --- /dev/null +++ b/ApiCore-Backend/src/tasks/tasks.controller.ts @@ -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); + } +} diff --git a/ApiCore-Backend/src/tasks/tasks.module.ts b/ApiCore-Backend/src/tasks/tasks.module.ts new file mode 100644 index 0000000..e3adeb3 --- /dev/null +++ b/ApiCore-Backend/src/tasks/tasks.module.ts @@ -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 {} diff --git a/ApiCore-Backend/src/tasks/tasks.service.spec.ts b/ApiCore-Backend/src/tasks/tasks.service.spec.ts new file mode 100644 index 0000000..1bc21da --- /dev/null +++ b/ApiCore-Backend/src/tasks/tasks.service.spec.ts @@ -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); + }); +}); diff --git a/ApiCore-Backend/src/tasks/tasks.service.ts b/ApiCore-Backend/src/tasks/tasks.service.ts new file mode 100644 index 0000000..0b260da --- /dev/null +++ b/ApiCore-Backend/src/tasks/tasks.service.ts @@ -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 { + return this.prisma.task.findMany({ + where: filter, + orderBy: [{ dueDate: 'asc' }, { createdAt: 'desc' }], + }); + } + + async findById(id: string): Promise { + 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 { + 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 { + 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 { + 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; + } +} diff --git a/ApiCore-Backend/test/tasks.e2e-spec.ts b/ApiCore-Backend/test/tasks.e2e-spec.ts new file mode 100644 index 0000000..ac553d1 --- /dev/null +++ b/ApiCore-Backend/test/tasks.e2e-spec.ts @@ -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; + 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(); + }); +}); diff --git a/TASKS.md b/TASKS.md index 6c09ee1..5ad16ae 100644 --- a/TASKS.md +++ b/TASKS.md @@ -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