From 3f20133d02864bcfb05c4847d61f76e7774a2c9e Mon Sep 17 00:00:00 2001 From: Nick Szaunig Date: Sat, 22 Aug 2026 22:26:16 +0200 Subject: [PATCH] feat(backend): Inventar (Modul 24) EquipmentModule (GET/POST /equipment, GET/PATCH /equipment/:id) fuer Ausruestungsgegenstaende (Inventarnummer, Bezeichnung, Hersteller, Kaufdatum, Zustand, Standort, Wartungsdatum). Neues Prisma-Modell Equipment (Migration equipment). --- .../20260822202209_equipment/migration.sql | 24 +++++ ApiCore-Backend/prisma/schema.prisma | 26 +++++ ApiCore-Backend/src/app.module.ts | 2 + .../src/equipment/dto/create-equipment.dto.ts | 32 ++++++ .../src/equipment/dto/update-equipment.dto.ts | 11 ++ .../src/equipment/equipment.controller.ts | 53 +++++++++ .../src/equipment/equipment.module.ts | 9 ++ .../src/equipment/equipment.service.spec.ts | 81 ++++++++++++++ .../src/equipment/equipment.service.ts | 69 ++++++++++++ ApiCore-Backend/test/equipment.e2e-spec.ts | 102 ++++++++++++++++++ TASKS.md | 2 +- 11 files changed, 410 insertions(+), 1 deletion(-) create mode 100644 ApiCore-Backend/prisma/migrations/20260822202209_equipment/migration.sql create mode 100644 ApiCore-Backend/src/equipment/dto/create-equipment.dto.ts create mode 100644 ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts create mode 100644 ApiCore-Backend/src/equipment/equipment.controller.ts create mode 100644 ApiCore-Backend/src/equipment/equipment.module.ts create mode 100644 ApiCore-Backend/src/equipment/equipment.service.spec.ts create mode 100644 ApiCore-Backend/src/equipment/equipment.service.ts create mode 100644 ApiCore-Backend/test/equipment.e2e-spec.ts diff --git a/ApiCore-Backend/prisma/migrations/20260822202209_equipment/migration.sql b/ApiCore-Backend/prisma/migrations/20260822202209_equipment/migration.sql new file mode 100644 index 0000000..0a25b07 --- /dev/null +++ b/ApiCore-Backend/prisma/migrations/20260822202209_equipment/migration.sql @@ -0,0 +1,24 @@ +-- CreateEnum +CREATE TYPE "EquipmentCondition" AS ENUM ('NEW', 'GOOD', 'WORN', 'DEFECTIVE'); + +-- CreateTable +CREATE TABLE "Equipment" ( + "id" TEXT NOT NULL, + "inventoryNumber" TEXT NOT NULL, + "name" TEXT NOT NULL, + "manufacturer" TEXT, + "purchaseDate" TIMESTAMP(3), + "condition" "EquipmentCondition" NOT NULL DEFAULT 'GOOD', + "location" TEXT, + "maintenanceDate" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "Equipment_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "Equipment_inventoryNumber_key" ON "Equipment"("inventoryNumber"); + +-- CreateIndex +CREATE INDEX "Equipment_condition_idx" ON "Equipment"("condition"); diff --git a/ApiCore-Backend/prisma/schema.prisma b/ApiCore-Backend/prisma/schema.prisma index e8b7679..e1c8eb0 100644 --- a/ApiCore-Backend/prisma/schema.prisma +++ b/ApiCore-Backend/prisma/schema.prisma @@ -672,3 +672,29 @@ model BottlingBatch { @@index([honeyBatchId]) @@index([productId]) } + +// --------------------------------------------------------------------------- +// Inventar (Abschnitt 4.22) – v1.x Modul 24 +// --------------------------------------------------------------------------- + +enum EquipmentCondition { + NEW + GOOD + WORN + DEFECTIVE +} + +model Equipment { + id String @id @default(cuid()) + inventoryNumber String @unique + name String + manufacturer String? + purchaseDate DateTime? + condition EquipmentCondition @default(GOOD) + location String? + maintenanceDate DateTime? + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + @@index([condition]) +} diff --git a/ApiCore-Backend/src/app.module.ts b/ApiCore-Backend/src/app.module.ts index 9d697ea..b12efd2 100644 --- a/ApiCore-Backend/src/app.module.ts +++ b/ApiCore-Backend/src/app.module.ts @@ -24,6 +24,7 @@ import { ExportModule } from './export/export.module'; import { QrModule } from './qr/qr.module'; import { ProductsModule } from './products/products.module'; import { BottlingModule } from './bottling/bottling.module'; +import { EquipmentModule } from './equipment/equipment.module'; import { JwtAuthGuard } from './auth/guards/jwt-auth.guard'; import { RolesGuard } from './auth/guards/roles.guard'; @@ -53,6 +54,7 @@ import { RolesGuard } from './auth/guards/roles.guard'; QrModule, ProductsModule, BottlingModule, + EquipmentModule, ], controllers: [], providers: [ diff --git a/ApiCore-Backend/src/equipment/dto/create-equipment.dto.ts b/ApiCore-Backend/src/equipment/dto/create-equipment.dto.ts new file mode 100644 index 0000000..8e66bdc --- /dev/null +++ b/ApiCore-Backend/src/equipment/dto/create-equipment.dto.ts @@ -0,0 +1,32 @@ +import { ApiProperty, ApiPropertyOptional } from '@nestjs/swagger'; +import { IsDateString, IsOptional, IsString } from 'class-validator'; + +export class CreateEquipmentDto { + @ApiProperty() + @IsString() + inventoryNumber!: string; + + @ApiProperty() + @IsString() + name!: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + manufacturer?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + purchaseDate?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsString() + location?: string; + + @ApiPropertyOptional() + @IsOptional() + @IsDateString() + maintenanceDate?: string; +} diff --git a/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts b/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts new file mode 100644 index 0000000..f474d88 --- /dev/null +++ b/ApiCore-Backend/src/equipment/dto/update-equipment.dto.ts @@ -0,0 +1,11 @@ +import { ApiPropertyOptional, PartialType } from '@nestjs/swagger'; +import { IsEnum, IsOptional } from 'class-validator'; +import { EquipmentCondition } from '../../../generated/prisma'; +import { CreateEquipmentDto } from './create-equipment.dto'; + +export class UpdateEquipmentDto extends PartialType(CreateEquipmentDto) { + @ApiPropertyOptional({ enum: EquipmentCondition }) + @IsOptional() + @IsEnum(EquipmentCondition) + condition?: EquipmentCondition; +} diff --git a/ApiCore-Backend/src/equipment/equipment.controller.ts b/ApiCore-Backend/src/equipment/equipment.controller.ts new file mode 100644 index 0000000..ba3a7bf --- /dev/null +++ b/ApiCore-Backend/src/equipment/equipment.controller.ts @@ -0,0 +1,53 @@ +import { + Body, + Controller, + Get, + Param, + Patch, + Post, + Query, +} from '@nestjs/common'; +import { ApiBearerAuth, ApiTags } from '@nestjs/swagger'; +import { EquipmentCondition, Role } 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 { CreateEquipmentDto } from './dto/create-equipment.dto'; +import { UpdateEquipmentDto } from './dto/update-equipment.dto'; +import { EquipmentService } from './equipment.service'; + +@ApiTags('equipment') +@ApiBearerAuth() +@Controller('equipment') +export class EquipmentController { + constructor(private readonly equipmentService: EquipmentService) {} + + @Get() + findAll(@Query('condition') condition?: EquipmentCondition) { + return this.equipmentService.findAll({ condition }); + } + + @Get(':id') + findOne(@Param('id') id: string) { + return this.equipmentService.findById(id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Post() + create( + @Body() dto: CreateEquipmentDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.equipmentService.create(dto, actor.id); + } + + @Roles(Role.ADMIN, Role.BEEKEEPER) + @Patch(':id') + update( + @Param('id') id: string, + @Body() dto: UpdateEquipmentDto, + @CurrentUser() actor: AuthenticatedUser, + ) { + return this.equipmentService.update(id, dto, actor.id); + } +} diff --git a/ApiCore-Backend/src/equipment/equipment.module.ts b/ApiCore-Backend/src/equipment/equipment.module.ts new file mode 100644 index 0000000..b9c9ac9 --- /dev/null +++ b/ApiCore-Backend/src/equipment/equipment.module.ts @@ -0,0 +1,9 @@ +import { Module } from '@nestjs/common'; +import { EquipmentController } from './equipment.controller'; +import { EquipmentService } from './equipment.service'; + +@Module({ + controllers: [EquipmentController], + providers: [EquipmentService], +}) +export class EquipmentModule {} diff --git a/ApiCore-Backend/src/equipment/equipment.service.spec.ts b/ApiCore-Backend/src/equipment/equipment.service.spec.ts new file mode 100644 index 0000000..132c4be --- /dev/null +++ b/ApiCore-Backend/src/equipment/equipment.service.spec.ts @@ -0,0 +1,81 @@ +import { NotFoundException } from '@nestjs/common'; +import { EquipmentService } from './equipment.service'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; + +describe('EquipmentService', () => { + let service: EquipmentService; + let prisma: { + equipment: { + findMany: jest.Mock; + findUnique: jest.Mock; + create: jest.Mock; + update: jest.Mock; + }; + }; + let auditLog: { record: jest.Mock }; + + beforeEach(() => { + prisma = { + equipment: { + findMany: jest.fn(), + findUnique: jest.fn(), + create: jest.fn(), + update: jest.fn(), + }, + }; + auditLog = { record: jest.fn() }; + service = new EquipmentService( + prisma as unknown as PrismaService, + auditLog as unknown as AuditLogService, + ); + }); + + it('creates equipment and records an audit log entry', async () => { + const created = { + id: 'e1', + inventoryNumber: 'INV-1', + name: 'Honigschleuder', + }; + prisma.equipment.create.mockResolvedValueOnce(created); + + const result = await service.create( + { inventoryNumber: 'INV-1', name: 'Honigschleuder' }, + 'user-1', + ); + + expect(result).toEqual(created); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ entityType: 'Equipment', action: 'CREATE' }), + ); + }); + + it('throws NotFoundException when equipment does not exist', async () => { + prisma.equipment.findUnique.mockResolvedValueOnce(null); + await expect(service.findById('missing')).rejects.toBeInstanceOf( + NotFoundException, + ); + }); + + it('updates equipment condition and records before/after audit data', async () => { + const before = { id: 'e1', condition: 'GOOD' }; + const after = { id: 'e1', condition: 'WORN' }; + prisma.equipment.findUnique.mockResolvedValueOnce(before); + prisma.equipment.update.mockResolvedValueOnce(after); + + const result = await service.update( + 'e1', + { condition: 'WORN' } as never, + 'user-1', + ); + + expect(result).toEqual(after); + expect(auditLog.record).toHaveBeenCalledWith( + expect.objectContaining({ + action: 'UPDATE', + beforeData: before, + afterData: after, + }), + ); + }); +}); diff --git a/ApiCore-Backend/src/equipment/equipment.service.ts b/ApiCore-Backend/src/equipment/equipment.service.ts new file mode 100644 index 0000000..fdd4ba5 --- /dev/null +++ b/ApiCore-Backend/src/equipment/equipment.service.ts @@ -0,0 +1,69 @@ +import { Injectable, NotFoundException } from '@nestjs/common'; +import { PrismaService } from '../prisma/prisma.service'; +import { AuditLogService } from '../audit/audit-log.service'; +import { Equipment, EquipmentCondition } from '../../generated/prisma'; +import { CreateEquipmentDto } from './dto/create-equipment.dto'; +import { UpdateEquipmentDto } from './dto/update-equipment.dto'; + +export interface EquipmentFilter { + condition?: EquipmentCondition; +} + +@Injectable() +export class EquipmentService { + constructor( + private readonly prisma: PrismaService, + private readonly auditLog: AuditLogService, + ) {} + + async findAll(filter: EquipmentFilter = {}): Promise { + return this.prisma.equipment.findMany({ + where: { condition: filter.condition }, + orderBy: { name: 'asc' }, + }); + } + + async findById(id: string): Promise { + const equipment = await this.prisma.equipment.findUnique({ where: { id } }); + if (!equipment) { + throw new NotFoundException('Equipment not found'); + } + return equipment; + } + + async create( + dto: CreateEquipmentDto, + actingUserId: string, + ): Promise { + const equipment = await this.prisma.equipment.create({ data: { ...dto } }); + await this.auditLog.record({ + entityType: 'Equipment', + entityId: equipment.id, + action: 'CREATE', + userId: actingUserId, + afterData: equipment, + }); + return equipment; + } + + async update( + id: string, + dto: UpdateEquipmentDto, + actingUserId: string, + ): Promise { + const before = await this.findById(id); + const equipment = await this.prisma.equipment.update({ + where: { id }, + data: { ...dto }, + }); + await this.auditLog.record({ + entityType: 'Equipment', + entityId: id, + action: 'UPDATE', + userId: actingUserId, + beforeData: before, + afterData: equipment, + }); + return equipment; + } +} diff --git a/ApiCore-Backend/test/equipment.e2e-spec.ts b/ApiCore-Backend/test/equipment.e2e-spec.ts new file mode 100644 index 0000000..b424e55 --- /dev/null +++ b/ApiCore-Backend/test/equipment.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 EquipmentBody { + id: string; + inventoryNumber: string; + condition: string; +} + +describe('Equipment (e2e)', () => { + let app: INestApplication; + let prisma: PrismaService; + const beekeeperEmail = `equipment-e2e-beekeeper-${Date.now()}@example.com`; + const staffEmail = `equipment-e2e-staff-${Date.now()}@example.com`; + const password = 'SuperSecret123!'; + let beekeeperId: string; + let staffId: string; + let beekeeperToken: string; + let staffToken: string; + let equipmentId: string; + + beforeAll(async () => { + app = await createTestApp(); + prisma = app.get(PrismaService); + + const passwordHash = await bcrypt.hash(password, 12); + const beekeeper = await prisma.user.create({ + data: { + email: beekeeperEmail, + passwordHash, + name: 'Beekeeper', + role: 'BEEKEEPER', + }, + }); + beekeeperId = beekeeper.id; + const staff = await prisma.user.create({ + data: { email: staffEmail, passwordHash, name: 'Staff', role: 'STAFF' }, + }); + staffId = staff.id; + beekeeperToken = (await login(app, beekeeperEmail, password)).accessToken; + staffToken = (await login(app, staffEmail, password)).accessToken; + }); + + afterAll(async () => { + if (equipmentId) { + await prisma.equipment.deleteMany({ where: { id: equipmentId } }); + } + await prisma.refreshToken.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.auditLog.deleteMany({ + where: { userId: { in: [beekeeperId, staffId] } }, + }); + await prisma.user.deleteMany({ + where: { id: { in: [beekeeperId, staffId] } }, + }); + await app.close(); + }); + + it('rejects create for a STAFF user', () => { + return request(app.getHttpServer()) + .post('/api/v1/equipment') + .set('Authorization', `Bearer ${staffToken}`) + .send({ inventoryNumber: `INV-${Date.now()}`, name: 'Honigschleuder' }) + .expect(403); + }); + + it('lets a BEEKEEPER create equipment', async () => { + const response = await request(app.getHttpServer()) + .post('/api/v1/equipment') + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ inventoryNumber: `INV-${Date.now()}`, name: 'Honigschleuder' }) + .expect(201); + const equipment = response.body as EquipmentBody; + equipmentId = equipment.id; + expect(equipment.condition).toBe('GOOD'); + }); + + it('lists equipment for any authenticated role', async () => { + const response = await request(app.getHttpServer()) + .get('/api/v1/equipment') + .set('Authorization', `Bearer ${staffToken}`) + .expect(200); + const items = response.body as EquipmentBody[]; + expect(items.some((item) => item.id === equipmentId)).toBe(true); + }); + + it('updates equipment condition', async () => { + const response = await request(app.getHttpServer()) + .patch(`/api/v1/equipment/${equipmentId}`) + .set('Authorization', `Bearer ${beekeeperToken}`) + .send({ condition: 'WORN' }) + .expect(200); + const equipment = response.body as EquipmentBody; + expect(equipment.condition).toBe('WORN'); + }); +}); diff --git a/TASKS.md b/TASKS.md index c2096ee..8afcdc1 100644 --- a/TASKS.md +++ b/TASKS.md @@ -195,7 +195,7 @@ Reihenfolge = Bau-Reihenfolge. Gleiche Haken-Regel wie MVP: erst setzen, wenn Li - Backend: `EquipmentModule` (`Equipment`: Inventarnummer, Bezeichnung, Hersteller, Kaufdatum, Zustand, Standort, Wartungsdatum) - Endpunkte: `GET/POST /api/v1/equipment`, `GET/PATCH /api/v1/equipment/:id` - Frontend: `/equipment`, `/equipment/new`, `/equipment/:id` -- [ ] Backend-Modul fertig, getestet, grün, committet +- [x] Backend-Modul fertig, getestet, grün, committet - [ ] Frontend-Views fertig, getestet, grün, committet ### 25. Kalender