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).
This commit is contained in:
Nick Szaunig committed 2026-08-22 22:26:16 +02:00
1 parent f4df719df7
commit 3f20133d02
11 files changed
+410 -1

No files matched your search

@@ -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");
+26
View File
@@ -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])
}
+2
View File
@@ -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: [
@@ -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;
}
@@ -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;
}
@@ -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);
}
}
@@ -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 {}
@@ -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,
}),
);
});
});
@@ -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<Equipment[]> {
return this.prisma.equipment.findMany({
where: { condition: filter.condition },
orderBy: { name: 'asc' },
});
}
async findById(id: string): Promise<Equipment> {
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<Equipment> {
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<Equipment> {
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;
}
}
+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 EquipmentBody {
id: string;
inventoryNumber: string;
condition: string;
}
describe('Equipment (e2e)', () => {
let app: INestApplication<App>;
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');
});
});